diff --git a/docs/README.md b/docs/README.md index 2c65c44ab..293d11ea1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ | [`requirements/`](requirements/) | What v2 must do. Owned by the product side; changed through issues, not feature pull requests. | | [`adr/`](adr/README.md) | Why v2 is built the way it is. Architecture decision records, one file per decision. | | [`spikes/`](spikes/) | Time-boxed experiments that settled an open point in an ADR, with their evidence. | +| [`api/`](api/delivery.md) | How frontends and agents read published content over HTTP. | ## Requirements diff --git a/docs/api/delivery.md b/docs/api/delivery.md new file mode 100644 index 000000000..5b8122d04 --- /dev/null +++ b/docs/api/delivery.md @@ -0,0 +1,58 @@ +# Delivery API + +The delivery API serves published content to frontends and agents over HTTP (API-01). It is read-only and anonymous: anyone who can reach the host can read what is published, and nothing else. + +## What it serves + +Only the published version of each entry is served, and only while the entry is not deleted. Drafts, unpublished entries, archived entries and deleted entries answer 404, the same as an id that never existed. + +Each item is a resolved view (CNT-04): + +- A reference to an entity field holds that entity's current published value. A newer draft of the entity changes nothing until it is published. +- A reference that has no published value is `null`, and `unresolved` says why: `notPublished`, `noValue` or `deleted`. +- References to whole entries and to media stay ids. Read a referenced entry at `/api/v1/entries/{id}`. + +Entities are content too, so their fields hold facts as stored: each value with its source, owner and validity. + +## Endpoints + +| Request | Returns | +| --- | --- | +| `GET /api/v1/content/{type}` | The published entries of a content type, in the order they were created. | +| `GET /api/v1/entries/{id}` | One published entry. | + +`GET /api/v1/content/{type}` takes these query parameters: + +| Parameter | Meaning | +| --- | --- | +| `limit` | Items per page, from 1 to 100. Defaults to 20. | +| `after` | The `next` cursor of the previous page. | +| `references` | Keeps the entries whose published version points at this entry, entity or media asset, such as every product page that shows one product. | + +A list looks like this: + +```json +{ + "items": [ + { + "id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", + "type": "product-page", + "version": 3, + "fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" }, + "unresolved": [] + } + ], + "next": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e" +} +``` + +`next` is `null` on the last page. Pages are keyed by id, so entries added while a client pages through do not shift the pages it has not read yet. + +## Errors + +Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`application/problem+json`). `errors` lists each error with a stable `code`: + +| Status | Codes | +| --- | --- | +| 400 | `limit_invalid`, `parameter_invalid` | +| 404 | `entry_not_found`, `content_type_not_found` | diff --git a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs index 64e34f94c..1e8c53a30 100644 --- a/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs +++ b/src/FluentCMS.Api/ApiServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ namespace FluentCMS.Api; public static class ApiServiceCollectionExtensions { - // The delivery API arrives with F-API; until then this registers nothing. + // The delivery API reads through Core services that Infrastructure registers, so it adds none of its own yet. public static IServiceCollection AddFluentCmsApi(this IServiceCollection services) { return services; diff --git a/src/FluentCMS.Api/DeliveryEndpoints.cs b/src/FluentCMS.Api/DeliveryEndpoints.cs new file mode 100644 index 000000000..230f668d9 --- /dev/null +++ b/src/FluentCMS.Api/DeliveryEndpoints.cs @@ -0,0 +1,126 @@ +using System.Globalization; +using System.Text.Json; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace FluentCMS.Api; + +// The read-only delivery API (API-01). Anyone may read published content, so no endpoint asks who the caller is. +public static class DeliveryEndpoints +{ + public const string Prefix = "/api/v1"; + + public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints) + { + var api = endpoints.MapGroup(Prefix).AllowAnonymous(); + api.MapGet("/content/{type}", ListAsync); + api.MapGet("/entries/{id}", GetAsync); + + // Ids are parsed by the handlers and every other path under the prefix is answered here, so no API call falls through to the + // admin's not-found page. + api.MapGet("/{**path}", (string? path) => Problem( + [new Error(DeliveryErrorCodes.EndpointNotFound, $"There is no delivery endpoint at {Prefix}/{path}; see docs/api/delivery.md.")])); + return api; + } + + private static async Task ListAsync( + string type, + string? limit, + string? after, + string? references, + IPublishedContent content, + CancellationToken cancellationToken) + { + var errors = new List(); + var size = PublishedQuery.DefaultLimit; + if (limit is not null && !int.TryParse(limit, NumberStyles.None, CultureInfo.InvariantCulture, out size)) + { + errors.Add(new Error(DeliveryErrorCodes.LimitInvalid, $"'limit' is '{limit}'; it must be a whole number from 1 to {PublishedQuery.MaxLimit}.")); + } + + var cursor = Id(after, "after", "the next cursor of a previous page", errors); + var target = Id(references, "references", "the id of an entry, entity or media asset", errors); + if (errors.Count > 0) + { + return Problem(errors); + } + + var page = await content.ListAsync(new PublishedQuery(type, size, cursor, target), cancellationToken); + return page.IsSuccess + ? Results.Json(new PageResponse([.. page.Value.Items.Select(Item)], page.Value.Next)) + : Problem(page.Errors); + } + + private static async Task GetAsync(string id, IPublishedContent content, CancellationToken cancellationToken) + { + var errors = new List(); + if (Id(id, "id", "the id of an entry", errors) is not { } entryId) + { + return Problem(errors); + } + + var item = await content.GetAsync(entryId, cancellationToken); + return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors); + } + + private static Guid? Id(string? value, string name, string form, List errors) + { + if (value is null) + { + return null; + } + + if (Guid.TryParse(value, out var id)) + { + return id; + } + + errors.Add(new Error(DeliveryErrorCodes.ParameterInvalid, $"'{name}' is '{value}'; it must be {form}, a UUID.")); + return null; + } + + private static ItemResponse Item(PublishedItem item) + { + return new ItemResponse( + item.Id, + item.ContentType, + item.Version, + item.Fields, + [.. item.Unresolved.Select(reference => new UnresolvedResponse( + reference.Path, + reference.EntityId, + reference.Field, + JsonNamingPolicy.CamelCase.ConvertName(reference.Reason.ToString())))]); + } + + // RFC 9457 problem details with every error, so a body is always written and the admin's not-found page never answers instead. + private static IResult Problem(IReadOnlyList errors) + { + var status = errors[0].Code switch + { + ContentErrorCodes.EntryNotFound or SchemaErrorCodes.ContentTypeNotFound or DeliveryErrorCodes.EndpointNotFound => StatusCodes.Status404NotFound, + AgentPermissionErrorCodes.ScopeDenied => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status400BadRequest, + }; + + return Results.Problem( + statusCode: status, + detail: string.Join(" ", errors.Select(error => error.Message)), + extensions: new Dictionary + { + ["errors"] = errors.Select(error => new { error.Code, error.Message }).ToList(), + }); + } + + private sealed record PageResponse(IReadOnlyList Items, Guid? Next); + + private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList Unresolved); + + private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason); +} diff --git a/src/FluentCMS.Api/FluentCMS.Api.csproj b/src/FluentCMS.Api/FluentCMS.Api.csproj index 460b63b29..9fe0bd32d 100644 --- a/src/FluentCMS.Api/FluentCMS.Api.csproj +++ b/src/FluentCMS.Api/FluentCMS.Api.csproj @@ -1,11 +1,11 @@ - + - + diff --git a/src/FluentCMS.Core/Delivery/IPublishedContent.cs b/src/FluentCMS.Core/Delivery/IPublishedContent.cs new file mode 100644 index 000000000..8ebe61661 --- /dev/null +++ b/src/FluentCMS.Core/Delivery/IPublishedContent.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using FluentCMS.Core.Content; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Delivery; + +// What the delivery API serves (API-01): the published version of each entry that has one and is not deleted, as a resolved view +// (CNT-04). Drafts, unpublished and deleted entries are not found, so a reader cannot tell them from ids that never existed. A read: +// it appends no audit event. +public interface IPublishedContent +{ + // The published entries of one content type, by id, which follows the order of creation. + Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default); + + Task> GetAsync(Guid id, CancellationToken cancellationToken = default); +} + +// After is the Next of the previous page. References keeps the entries whose published version points at that entry, entity or +// media asset, read from the reference index (ADR-0005). +public sealed record PublishedQuery(string ContentType, int Limit = PublishedQuery.DefaultLimit, Guid? After = null, Guid? References = null) +{ + public const int DefaultLimit = 20; + + public const int MaxLimit = 100; +} + +// Next is null on the last page. +public sealed record PublishedPage(IReadOnlyList Items, Guid? Next); + +public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList Unresolved); + +public static class DeliveryErrorCodes +{ + public const string LimitInvalid = "limit_invalid"; + public const string ParameterInvalid = "parameter_invalid"; + public const string EndpointNotFound = "endpoint_not_found"; +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index fb11e874a..f3d39ee5c 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -67,6 +67,7 @@ public static WebApplication BuildWebApplication(string[] args, Action> ResolveAsync( + FluentCmsDbContext context, + IReadOnlyList versions, + CancellationToken cancellationToken) + { + if (versions.Count == 0) + { + return []; + } + + var ids = versions.Select(version => version.EntryId).Distinct().ToList(); + var numbers = versions.Select(version => version.Version).Distinct().ToList(); + var wanted = versions.Select(version => (version.EntryId, version.Version)).ToHashSet(); + + // Every pair of a wanted entry and a wanted number is fetched, and the pairs not asked for are dropped here. + var stored = (await context.EntryVersions + .AsNoTracking() + .Where(row => ids.Contains(row.EntryId) && numbers.Contains(row.Version)) + .Select(row => new { row.EntryId, row.Version, row.Fields }) + .ToListAsync(cancellationToken)) + .Where(row => wanted.Contains((row.EntryId, row.Version))) + .ToDictionary(row => (row.EntryId, row.Version), row => row.Fields); + + var references = (await context.EntryReferences + .AsNoTracking() + .Where(row => ids.Contains(row.EntryId) + && numbers.Contains(row.Version) + && row.TargetKind == ReferenceKind.Entry + && row.TargetField != null) + .Select(row => new { row.EntryId, row.Version, row.Path, row.TargetId, Field = row.TargetField! }) + .ToListAsync(cancellationToken)) + .Where(row => wanted.Contains((row.EntryId, row.Version))) + .ToLookup(row => (row.EntryId, row.Version)); + + var published = await PublishedFieldsAsync(context, references.SelectMany(group => group).Select(row => row.TargetId).Distinct().ToList(), cancellationToken); + + return versions.Select(version => + { + var fields = stored.GetValueOrDefault((version.EntryId, version.Version)) + ?? throw new InvalidOperationException($"Entry {version.EntryId} has no stored version {version.Version}."); + var values = new Dictionary(StringComparer.Ordinal); + var unresolved = new List(); + + foreach (var reference in references[(version.EntryId, version.Version)].OrderBy(reference => reference.Path, StringComparer.Ordinal)) + { + var (deleted, entity) = published.GetValueOrDefault(reference.TargetId); + var value = entity is { } fieldsOfEntity ? FactValue(fieldsOfEntity, reference.Field) : null; + values[reference.Path] = value; + + if (value is null) + { + var reason = deleted ? UnresolvedReason.Deleted : entity is null ? UnresolvedReason.NotPublished : UnresolvedReason.NoValue; + unresolved.Add(new UnresolvedReference(reference.Path, reference.TargetId, reference.Field, reason)); + } + } + + using var document = JsonDocument.Parse(fields); + return new ResolvedEntry(version.EntryId, version.ContentType, version.Version, ResolvedFields.Replace(document.RootElement, values), unresolved); + }).ToList(); + } + + // Each entity's published fields, read in one query: none when it is deleted or has no published version. + private static async Task> PublishedFieldsAsync( + FluentCmsDbContext context, + List ids, + CancellationToken cancellationToken) + { + if (ids.Count == 0) + { + return []; + } + + var rows = await ( + from entity in context.Entries.AsNoTracking() + where ids.Contains(entity.Id) && entity.SiteId == SiteId + from version in context.EntryVersions.AsNoTracking() + .Where(version => version.EntryId == entity.Id && version.Version == entity.PublishedVersion) + .DefaultIfEmpty() + select new { entity.Id, Deleted = entity.DeletedAt != null, Fields = version == null ? null : version.Fields }) + .ToListAsync(cancellationToken); + + return rows.ToDictionary( + row => row.Id, + row => (row.Deleted, row.Deleted || row.Fields is null ? (JsonElement?)null : JsonSerializer.Deserialize(row.Fields))); + } + + // An entity stores each field as a fact; the view shows the fact's value (SCH-03). + private static JsonElement? FactValue(JsonElement entity, string field) + { + return entity.TryGetProperty(field, out var fact) + && fact.ValueKind == JsonValueKind.Object + && fact.TryGetProperty(FactMetadata.Value, out var value) + && value.ValueKind != JsonValueKind.Null + ? value + : null; + } +} + +internal sealed record VersionToResolve(Guid EntryId, string ContentType, int Version); diff --git a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs index ed05fa9ef..f762663f9 100644 --- a/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs +++ b/src/FluentCMS.Infrastructure/Content/ResolvedEntries.cs @@ -1,8 +1,6 @@ -using System.Text.Json; using FluentCMS.Core.Agents; using FluentCMS.Core.Content; using FluentCMS.Core.Results; -using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -35,76 +33,14 @@ join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals typ } var number = version ?? entry.LatestVersion; - var fields = await context.EntryVersions - .AsNoTracking() - .Where(row => row.EntryId == entryId && row.Version == number) - .Select(row => row.Fields) - .SingleOrDefaultAsync(cancellationToken); - if (fields is null) + if (!await context.EntryVersions.AnyAsync(row => row.EntryId == entryId && row.Version == number, cancellationToken)) { return Result.Failure( ContentErrorCodes.VersionNotFound, $"Entry {entryId} has no version {number}; its latest version is {entry.LatestVersion}."); } - var references = await context.EntryReferences - .AsNoTracking() - .Where(row => row.EntryId == entryId && row.Version == number && row.TargetKind == ReferenceKind.Entry && row.TargetField != null) - .Select(row => new { row.Path, row.TargetId, Field = row.TargetField! }) - .ToListAsync(cancellationToken); - - var published = await PublishedFieldsAsync(references.Select(reference => reference.TargetId).Distinct().ToList(), cancellationToken); - var values = new Dictionary(StringComparer.Ordinal); - var unresolved = new List(); - - foreach (var reference in references.OrderBy(reference => reference.Path, StringComparer.Ordinal)) - { - var (deleted, entity) = published.GetValueOrDefault(reference.TargetId); - var value = entity is { } fieldsOfEntity ? FactValue(fieldsOfEntity, reference.Field) : null; - values[reference.Path] = value; - - if (value is null) - { - var reason = deleted ? UnresolvedReason.Deleted : entity is null ? UnresolvedReason.NotPublished : UnresolvedReason.NoValue; - unresolved.Add(new UnresolvedReference(reference.Path, reference.TargetId, reference.Field, reason)); - } - } - - using var document = JsonDocument.Parse(fields); - return Result.Success( - new ResolvedEntry(entryId, entry.Slug, number, ResolvedFields.Replace(document.RootElement, values), unresolved)); - } - - // Each entity's published fields, read in one query: none when it is deleted or has no published version. - private async Task> PublishedFieldsAsync(List ids, CancellationToken cancellationToken) - { - if (ids.Count == 0) - { - return []; - } - - var rows = await ( - from entity in context.Entries.AsNoTracking() - where ids.Contains(entity.Id) && entity.SiteId == SiteId - from version in context.EntryVersions.AsNoTracking() - .Where(version => version.EntryId == entity.Id && version.Version == entity.PublishedVersion) - .DefaultIfEmpty() - select new { entity.Id, Deleted = entity.DeletedAt != null, Fields = version == null ? null : version.Fields }) - .ToListAsync(cancellationToken); - - return rows.ToDictionary( - row => row.Id, - row => (row.Deleted, row.Deleted || row.Fields is null ? (JsonElement?)null : JsonSerializer.Deserialize(row.Fields))); - } - - // An entity stores each field as a fact; the view shows the fact's value (SCH-03). - private static JsonElement? FactValue(JsonElement entity, string field) - { - return entity.TryGetProperty(field, out var fact) - && fact.ValueKind == JsonValueKind.Object - && fact.TryGetProperty(FactMetadata.Value, out var value) - && value.ValueKind != JsonValueKind.Null - ? value - : null; + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(entryId, entry.Slug, number)], cancellationToken); + return Result.Success(resolved[0]); } } diff --git a/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs new file mode 100644 index 000000000..43bc58ad3 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Delivery/PublishedContentReader.cs @@ -0,0 +1,106 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Delivery; + +// An agent that reads through here keeps to its scope (MCP-05); people and anonymous readers see every published entry. +internal sealed class PublishedContentReader(FluentCmsDbContext context, IAgentPermissions permissions) : IPublishedContent +{ + // Until multi-site (MST-01), every entry belongs to the one site (SCH-07). + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (query.Limit is < 1 or > PublishedQuery.MaxLimit) + { + return Result.Failure( + DeliveryErrorCodes.LimitInvalid, + $"The limit is {query.Limit}; it must be from 1 to {PublishedQuery.MaxLimit}."); + } + + var allowed = await permissions.CheckContentAsync(query.ContentType, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var typeId = await context.ContentTypes + .AsNoTracking() + .Where(row => row.SiteId == SiteId && row.Slug == query.ContentType) + .Select(row => (Guid?)row.Id) + .SingleOrDefaultAsync(cancellationToken); + if (typeId is null) + { + return Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site."); + } + + var entries = context.Entries + .AsNoTracking() + .Where(entry => entry.SiteId == SiteId && entry.ContentTypeId == typeId && entry.PublishedVersion != null && entry.DeletedAt == null); + + // Ids are version 7, so they sort in the order of creation and a page boundary does not move as entries are added. + if (query.After is { } after) + { + entries = entries.Where(entry => entry.Id > after); + } + + if (query.References is { } target) + { + entries = entries.Where(entry => context.EntryReferences + .Any(reference => reference.EntryId == entry.Id && reference.Version == entry.PublishedVersion && reference.TargetId == target)); + } + + var rows = await entries + .OrderBy(entry => entry.Id) + .Take(query.Limit + 1) + .Select(entry => new { entry.Id, Version = entry.PublishedVersion!.Value }) + .ToListAsync(cancellationToken); + + var page = rows.Take(query.Limit).ToList(); + var resolved = await EntryResolution.ResolveAsync( + context, + [.. page.Select(row => new VersionToResolve(row.Id, query.ContentType, row.Version))], + cancellationToken); + + return Result.Success(new PublishedPage( + [.. resolved.Select(Item)], + rows.Count > query.Limit ? page[^1].Id : null)); + } + + public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + var entry = await ( + from row in context.Entries.AsNoTracking() + join type in context.ContentTypes.AsNoTracking() on row.ContentTypeId equals type.Id + where row.Id == id && row.SiteId == SiteId && row.PublishedVersion != null && row.DeletedAt == null + select new { type.Slug, Version = row.PublishedVersion!.Value }) + .SingleOrDefaultAsync(cancellationToken); + if (entry is null) + { + return Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site."); + } + + var allowed = await permissions.CheckContentAsync(entry.Slug, AgentOperation.Read, cancellationToken: cancellationToken); + if (!allowed.IsSuccess) + { + return Result.Failure(allowed.Errors); + } + + var resolved = await EntryResolution.ResolveAsync(context, [new VersionToResolve(id, entry.Slug, entry.Version)], cancellationToken); + return Result.Success(Item(resolved[0])); + } + + private static PublishedItem Item(ResolvedEntry entry) + { + return new PublishedItem(entry.EntryId, entry.ContentType, entry.Version, entry.Fields, entry.Unresolved); + } +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 3ca85ac8a..0c8aaf6ee 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; using FluentCMS.Core.Entities; using FluentCMS.Core.Extensions; using FluentCMS.Core.Identity; @@ -16,6 +17,7 @@ using FluentCMS.Infrastructure.ChangeSets; using FluentCMS.Infrastructure.Checks; using FluentCMS.Infrastructure.Content; +using FluentCMS.Infrastructure.Delivery; using FluentCMS.Infrastructure.Entities; using FluentCMS.Infrastructure.Identity; using FluentCMS.Infrastructure.Jobs; @@ -117,6 +119,7 @@ private static void AddContent(IServiceCollection services, IConfiguration confi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs new file mode 100644 index 000000000..6fb0bf28b --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/DeliveryEndpointsTests.cs @@ -0,0 +1,161 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Api; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class DeliveryEndpointsTests +{ + private static readonly Guid Page = Guid.CreateVersion7(); + private static readonly Guid Product = Guid.CreateVersion7(); + + [Fact] + public async Task A_list_returns_items_with_their_resolved_fields_and_the_next_cursor() + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetFromJsonAsync($"/api/v1/content/product-page?limit=1&after={Product}&references={Product}"); + + Assert.Equal(new PublishedQuery("product-page", 1, Product, Product), content.Query); + var item = Assert.Single(response!["items"]!.AsArray())!; + Assert.Equal((Page.ToString(), "product-page", 3), ((string?)item["id"], (string?)item["type"], (int)item["version"]!)); + Assert.Equal(99, (int)item["fields"]!["price"]!); + var unresolved = Assert.Single(item["unresolved"]!.AsArray())!; + Assert.Equal(("/spec", "notPublished"), ((string?)unresolved["path"], (string?)unresolved["reason"])); + Assert.Equal(Page.ToString(), (string?)response["next"]); + } + + [Fact] + public async Task An_entry_reads_by_id_and_a_missing_one_is_a_problem_with_404() + { + await using var api = await StubApi.StartAsync(new StubContent()); + + var found = await api.Client.GetFromJsonAsync($"/api/v1/entries/{Page}"); + var missing = await api.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}"); + var unknownType = await api.Client.GetAsync("/api/v1/content/news"); + + Assert.Equal(Page.ToString(), (string?)found!["id"]); + await AssertProblemAsync(missing, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + } + + [Theory] + [InlineData("limit=ten", DeliveryErrorCodes.LimitInvalid, "'limit'")] + [InlineData("limit=-1", DeliveryErrorCodes.LimitInvalid, "'limit'")] + [InlineData("after=page-2", DeliveryErrorCodes.ParameterInvalid, "'after'")] + [InlineData("references=drill", DeliveryErrorCodes.ParameterInvalid, "'references'")] + public async Task A_parameter_that_does_not_parse_is_a_problem_with_400_that_names_it(string query, string code, string name) + { + var content = new StubContent(); + await using var api = await StubApi.StartAsync(content); + + var response = await api.Client.GetAsync($"/api/v1/content/product-page?{query}"); + + var problem = await AssertProblemAsync(response, HttpStatusCode.BadRequest, code); + Assert.Contains(name, (string?)problem["detail"]); + Assert.Null(content.Query); + } + + [Fact] + public async Task The_host_serves_the_api_to_anonymous_readers_from_the_database() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + + var unknownType = await host.Client.GetAsync("/api/v1/content/product-page"); + var unknownEntry = await host.Client.GetAsync($"/api/v1/entries/{Guid.CreateVersion7()}"); + var badLimit = await host.Client.GetAsync("/api/v1/content/product-page?limit=0"); + + await AssertProblemAsync(unknownType, HttpStatusCode.NotFound, SchemaErrorCodes.ContentTypeNotFound); + await AssertProblemAsync(unknownEntry, HttpStatusCode.NotFound, ContentErrorCodes.EntryNotFound); + await AssertProblemAsync(badLimit, HttpStatusCode.BadRequest, DeliveryErrorCodes.LimitInvalid); + } + + // The admin's not-found page answers every other path of the host, so the API answers each of its own. + [Theory] + [InlineData("/api/v1/entries/not-a-guid", HttpStatusCode.BadRequest, DeliveryErrorCodes.ParameterInvalid)] + [InlineData("/api/v1/entries/", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] + [InlineData("/api/v1/content/", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] + [InlineData("/api/v1/nothing/here", HttpStatusCode.NotFound, DeliveryErrorCodes.EndpointNotFound)] + public async Task A_path_or_id_the_api_does_not_know_is_a_problem_never_the_admin_page(string path, HttpStatusCode status, string code) + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + + await AssertProblemAsync(await host.Client.GetAsync(path), status, code); + } + + private static async Task AssertProblemAsync(HttpResponseMessage response, HttpStatusCode status, string code) + { + Assert.Equal(status, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + var problem = (await response.Content.ReadFromJsonAsync())!; + Assert.Equal(code, (string?)problem["errors"]![0]!["code"]); + return problem; + } + + // Answers like the reader would for one published page, and records the query it was asked. + private sealed class StubContent : IPublishedContent + { + public PublishedQuery? Query { get; private set; } + + public Task> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default) + { + Query = query; + return Task.FromResult(query.ContentType == "product-page" + ? Result.Success(new PublishedPage([Item()], Page)) + : Result.Failure(SchemaErrorCodes.ContentTypeNotFound, $"There is no content type '{query.ContentType}' in this site.")); + } + + public Task> GetAsync(Guid id, CancellationToken cancellationToken = default) + { + return Task.FromResult(id == Page + ? Result.Success(Item()) + : Result.Failure(ContentErrorCodes.EntryNotFound, $"There is no published entry {id} in this site.")); + } + + private static PublishedItem Item() + { + return new PublishedItem( + Page, + "product-page", + 3, + JsonSerializer.Deserialize($$"""{"product": "{{Product}}", "price": 99, "spec": null}"""), + [new UnresolvedReference("/spec", Guid.CreateVersion7(), "value", UnresolvedReason.NotPublished)]); + } + } + + // The endpoints alone on a free port, over the content given. + private sealed class StubApi(WebApplication app) : IAsyncDisposable + { + public HttpClient Client { get; } = new() { BaseAddress = new Uri(app.Urls.Single()) }; + + public static async Task StartAsync(IPublishedContent content) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(content); + var app = builder.Build(); + app.MapFluentCmsApi(); + await app.StartAsync(); + return new StubApi(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await app.StopAsync(); + await app.DisposeAsync(); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs new file mode 100644 index 000000000..b49951b77 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Delivery/PublishedContentTests.cs @@ -0,0 +1,206 @@ +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Delivery; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.ChangeSets; +using FluentCMS.IntegrationTests.Content; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.Content.EntryScenario; + +namespace FluentCMS.IntegrationTests.Delivery; + +public sealed class PublishedContentTests +{ + // The F-API acceptance: a frontend reads a product page with the product's price as published, whatever its drafts say. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_page_reads_as_published_with_its_price_resolved_to_the_published_value(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + await PublishAsync(scenario, product, 1); + await scenario.UpdateAsync(product, 1, ProductFields("109")); + var page = await PageAsync(scenario, product, "A sturdy drill"); + await PublishAsync(scenario, page, 1); + await scenario.UpdateAsync(page, 1, $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "Draft headline"}"""); + + var read = (await ContentAsync(scenario, content => content.GetAsync(page))).Value; + var entity = (await ContentAsync(scenario, content => content.GetAsync(product))).Value; + + Assert.Equal((page, "product-page", 1), (read.Id, read.ContentType, read.Version)); + AssertSameFields($$"""{"product": "{{product}}", "price": 99, "headline": "A sturdy drill"}""", read.Fields); + Assert.Empty(read.Unresolved); + Assert.Equal(("product", 1), (entity.ContentType, entity.Version)); + Assert.Equal(99, entity.Fields.GetProperty("price").GetProperty("value").GetInt32()); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Drafts_unpublished_and_deleted_entries_are_neither_listed_nor_read(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var live = await PageAsync(scenario, product, "Live"); + var draft = await PageAsync(scenario, product, "Draft"); + var deleted = await PageAsync(scenario, product, "Deleted"); + await PublishAsync(scenario, live, 1); + await PublishAsync(scenario, deleted, 1); + await scenario.RunAsync(entries => entries.DeleteAsync(deleted, 1)); + + var archived = await PageAsync(scenario, product, "Archived"); + await scenario.Schema.QueryAsync(context => context.Entries + .Where(row => row.Id == archived) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, (int?)null).SetProperty(row => row.State, EntryState.Archived))); + + var listed = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page")))).Value; + var products = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product")))).Value; + + Assert.Equal([live], listed.Items.Select(item => item.Id)); + Assert.Null(listed.Next); + Assert.Empty(products.Items); + Assert.Equal("/price", Assert.Single(listed.Items[0].Unresolved).Path); + foreach (var hidden in new[] { draft, deleted, archived, product, Guid.CreateVersion7() }) + { + Assert.Equal(ContentErrorCodes.EntryNotFound, Code(await ContentAsync(scenario, content => content.GetAsync(hidden)))); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Pages_follow_the_order_of_creation_and_end_without_a_next_cursor(string provider) + { + await using var scenario = await CreateAsync(provider); + var product = await ProductAsync(scenario, "99"); + var pages = new List(); + for (var number = 1; number <= 5; number++) + { + pages.Add(await PageAsync(scenario, product, $"Page {number}")); + await PageAsync(scenario, product, $"Draft {number}"); + await PublishAsync(scenario, pages[^1], 1); + } + + var read = new List(); + var cursors = new List(); + Guid? after = null; + do + { + var page = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 2, after)))).Value; + read.AddRange(page.Items.Select(item => item.Id)); + cursors.Add(page.Next); + after = page.Next; + } + while (after is not null); + + Assert.Equal(pages, read); + Assert.Equal([pages[1], pages[3], null], cursors); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task References_keeps_the_entries_whose_published_version_points_at_the_target(string provider) + { + await using var scenario = await CreateAsync(provider); + var (drill, saw) = (await ProductAsync(scenario, "99"), await ProductAsync(scenario, "49")); + var drillPage = await PageAsync(scenario, drill, "Drill"); + var sawPage = await PageAsync(scenario, saw, "Saw"); + var movedPage = await PageAsync(scenario, saw, "Was a saw"); + await PublishAsync(scenario, drillPage, 1); + await PublishAsync(scenario, sawPage, 1); + await PublishAsync(scenario, movedPage, 1); + await scenario.UpdateAsync(movedPage, 1, $$"""{"product": "{{drill}}", "price": "{{drill}}", "headline": "Now a drill"}"""); + + var drillPages = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", References: drill)))).Value; + var sawPages = (await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", References: saw)))).Value; + + Assert.Equal([drillPage], drillPages.Items.Select(item => item.Id)); + Assert.Equal([sawPage, movedPage], sawPages.Items.Select(item => item.Id)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_limit_out_of_range_or_an_unknown_type_fails(string provider) + { + await using var scenario = await CreateAsync(provider); + + Assert.Equal(DeliveryErrorCodes.LimitInvalid, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 0))))); + Assert.Equal(DeliveryErrorCodes.LimitInvalid, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 101))))); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Code(await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("news"))))); + Assert.Empty((await ContentAsync(scenario, content => content.ListAsync(new PublishedQuery("product-page", 100)))).Value.Items); + } + + // An agent that reads through these services keeps to its scope (MCP-05). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_only_published_content_of_types_it_may_read(string provider) + { + await using var scenario = await EntryChangeSetTests.ScenarioAsync(provider); + var (page, product) = await EntryChangeSetTests.CatalogAsync(scenario); + await scenario.PublishAsync( + ChangeOperation.Create(OperationKind.Create, ItemKind.Entity, product, null, 1).Value, + ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, page, null, 1).Value); + await scenario.UpdateAgentAsync(row => row.Types = ["product"]); + scenario.Initiator = ChangeSetScenario.Agent; + + await using var scope = scenario.Services.CreateAsyncScope(); + var content = scope.ServiceProvider.GetRequiredService(); + + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(await content.GetAsync(page))); + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(await content.ListAsync(new PublishedQuery("page")))); + Assert.Equal([product], (await content.ListAsync(new PublishedQuery("product"))).Value.Items.Select(item => item.Id)); + } + + private static async Task CreateAsync(string provider) + { + var scenario = await EntryScenario.CreateAsync(provider); + await scenario.Schema.CreateReferenceModelAsync(); + return scenario; + } + + private static string ProductFields(string price) + { + return $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact(price)}}}"""; + } + + private static async Task ProductAsync(EntryScenario scenario, string price) + { + var created = await scenario.CreateAsync("product", ProductFields(price)); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } + + private static async Task PageAsync(EntryScenario scenario, Guid product, string headline) + { + var created = await scenario.CreateAsync("product-page", $$"""{"product": "{{product}}", "price": "{{product}}", "headline": "{{headline}}"}"""); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return created.Value.Id; + } + + // Publish moves the pointer through change sets (#2519); these tests only need published versions. + private static Task PublishAsync(EntryScenario scenario, Guid entry, int version) + { + return scenario.Schema.QueryAsync(context => context.Entries + .Where(row => row.Id == entry) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, version))); + } + + private static string Code(Result result) + { + return Assert.Single(result.Errors).Code; + } + + private static async Task ContentAsync(EntryScenario scenario, Func> read) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } +}