Phase 4: POST /api/v1/facts #6

Merged
k.renner merged 1 commits from feat/facts into main 2026-09-24 14:51:05 +00:00
4 changed files with 90 additions and 1 deletions
+1
View File
@@ -11,6 +11,7 @@ public static class ApiV1
var v1 = app.MapGroup("/api/v1");
v1.MapReadEndpoints();
v1.MapAskEndpoints();
v1.MapFactEndpoints();
v1.MapGet("/status", async (StatusQuery status, CancellationToken ct) =>
Results.Ok(await status.GetAsync(includeAdapterChecks: true, ct))).AllowAnonymous();
+46
View File
@@ -0,0 +1,46 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
using Wiki.Application.Facts;
using Wiki.Core.Model;
namespace Wiki.Host.Api;
/// <summary>Wire shape of a deposited fact. `kind` must be "fact": the shape has no room for a hypothesis.</summary>
public sealed record FactDto(
[property: JsonPropertyName("title")] string? Title,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("kind")] string? Kind,
[property: JsonPropertyName("observed_at")] DateTimeOffset? ObservedAt,
[property: JsonPropertyName("content")] string? Content,
[property: JsonPropertyName("tags")] IReadOnlyList<string>? Tags);
public static class FactEndpoints
{
public static void MapFactEndpoints(this RouteGroupBuilder v1)
{
v1.MapPost("/facts", async (FactDto body, DepositFactUseCase deposit, ClaimsPrincipal user, TimeProvider clock, CancellationToken ct) =>
{
if (!string.Equals(body.Kind, "fact", StringComparison.OrdinalIgnoreCase))
return Results.UnprocessableEntity(new { error = "kind must be \"fact\"; raw sources hold verified observations only" });
var fact = new RawSource(
body.Title ?? string.Empty,
body.Source ?? string.Empty,
body.Content ?? string.Empty,
body.ObservedAt ?? clock.GetUtcNow(),
(body.Tags ?? []).Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList());
var errors = fact.Validate();
if (errors.Count > 0) return Results.UnprocessableEntity(new { error = string.Join("; ", errors) });
var by = new Identity(user.Identity?.Name ?? "unknown", IdentityKind.Machine);
try
{
var receipt = await deposit.DepositAsync(fact, by, ct);
return Results.Ok(new { receipt.Path, receipt.Commit, receipt.StoredAt, capturedBy = by.Name });
}
catch (HttpRequestException e)
{
return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway);
}
}).RequireAuthorization(Auth.Policies.Write);
}
}
+1
View File
@@ -90,6 +90,7 @@ builder.Services.AddSingleton<RankUseCase>();
builder.Services.AddSingleton<Wiki.Application.Reading.NoteQueries>();
builder.Services.AddSingleton<IChunker, Wiki.Core.Parsing.ParagraphChunker>();
builder.Services.AddSingleton<Wiki.Application.Ask.AskUseCase>();
builder.Services.AddSingleton<Wiki.Application.Facts.DepositFactUseCase>();
builder.Services.AddSingleton<StatusQuery>();
builder.Services.AddSingleton<SyncBackgroundService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncBackgroundService>());
+42 -1
View File
@@ -157,7 +157,7 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture<WikiHostFact
Assert.Contains(tags!, t => t.Tag == "ceph" && t.Count == 1);
var raw = await Client.GetFromJsonAsync<List<RawRow>>("/api/v1/raw");
Assert.Equal("2026-09-23-pending", Assert.Single(raw!).Slug);
Assert.Contains(raw!, r => r.Slug == "2026-09-23-pending");
var reports = await Client.GetFromJsonAsync<List<ReportRow>>("/api/v1/reports?type=audit");
Assert.Equal("Wiki audit", Assert.Single(reports!).Title);
@@ -196,6 +196,47 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture<WikiHostFact
Assert.Contains("answers every query", top[0].GetProperty("text").GetString());
}
[Fact]
public async Task FactsNeedAKeyAreValidatedAndLandInRaw()
{
var anonymous = await Client.PostAsJsonAsync("/api/v1/facts", new { title = "x", kind = "fact" });
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
var hypothesis = new HttpRequestMessage(HttpMethod.Post, "/api/v1/facts") { Content = JsonContent.Create(new { title = "t", source = "s", kind = "hypothesis", content = new string('x', 60) }) };
hypothesis.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Client.SendAsync(hypothesis)).StatusCode);
var tooShort = new HttpRequestMessage(HttpMethod.Post, "/api/v1/facts") { Content = JsonContent.Create(new { title = "t", source = "s", kind = "fact", content = "short" }) };
tooShort.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await Client.SendAsync(tooShort)).StatusCode);
var ok = new HttpRequestMessage(HttpMethod.Post, "/api/v1/facts")
{
Content = JsonContent.Create(new
{
title = "Talos v1.13.10 backports aes256k",
source = "kubectl get nodes -o wide, 2026-09-23",
kind = "fact",
observed_at = "2026-09-23T19:10:00Z",
content = "All three nodes report OS-IMAGE Talos (v1.13.10) and KERNEL 6.18.48-talos after the rolling upgrade.",
tags = new[] { "talos", "ceph" },
}),
};
ok.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
var response = await Client.SendAsync(ok);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var receipt = await response.Content.ReadFromJsonAsync<FactReceiptDto>();
Assert.NotNull(receipt);
Assert.StartsWith("raw/", receipt.Path);
Assert.EndsWith("-talos-v1-13-10-backports-aes256k.md", receipt.Path);
Assert.Equal("tester", receipt.CapturedBy);
var stored = factory.Store.Files[receipt.Path];
Assert.Contains("captured_by: tester", stored);
Assert.Contains("type: raw", stored);
Assert.Contains("tags: [talos, ceph]", stored);
}
private sealed record FactReceiptDto(string Path, string Commit, string CapturedBy);
private sealed record AskResponse(string Judge, double? Covered, Dictionary<string, System.Text.Json.JsonElement> Answers);
private sealed record NoteRow(string Slug, string Title);
private sealed record LinkRow(string? ToSlug, string? FromSlug, string Kind);