Phase 4: POST /api/v1/facts #6
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user