From 35887a9cbfe630cae7797a5b1bbb0949c43ec9f0 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Thu, 24 Sep 2026 16:29:06 +0200 Subject: [PATCH] facts: POST /api/v1/facts deposits a raw source through the store, attributed to the API key Co-Authored-By: Claude Fable 5.1 --- src/Wiki.Host/Api/ApiV1.cs | 1 + src/Wiki.Host/Api/FactEndpoints.cs | 46 +++++++++++++++++++++++++++ src/Wiki.Host/Program.cs | 1 + test/Wiki.Host.Tests/EndpointTests.cs | 43 ++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 src/Wiki.Host/Api/FactEndpoints.cs diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs index 249d1f6..ec36fb0 100644 --- a/src/Wiki.Host/Api/ApiV1.cs +++ b/src/Wiki.Host/Api/ApiV1.cs @@ -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(); diff --git a/src/Wiki.Host/Api/FactEndpoints.cs b/src/Wiki.Host/Api/FactEndpoints.cs new file mode 100644 index 0000000..809764e --- /dev/null +++ b/src/Wiki.Host/Api/FactEndpoints.cs @@ -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; + +/// Wire shape of a deposited fact. `kind` must be "fact": the shape has no room for a hypothesis. +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? 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); + } +} diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 85f0796..d2425af 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -90,6 +90,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index 23cb173..8a066a0 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -157,7 +157,7 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture t.Tag == "ceph" && t.Count == 1); var raw = await Client.GetFromJsonAsync>("/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>("/api/v1/reports?type=audit"); Assert.Equal("Wiki audit", Assert.Single(reports!).Title); @@ -196,6 +196,47 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture(); + 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 Answers); private sealed record NoteRow(string Slug, string Title); private sealed record LinkRow(string? ToSlug, string? FromSlug, string Kind); -- 2.54.0