Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions docs/api/delivery.md
Original file line number Diff line number Diff line change
@@ -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` |
2 changes: 1 addition & 1 deletion src/FluentCMS.Api/ApiServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
126 changes: 126 additions & 0 deletions src/FluentCMS.Api/DeliveryEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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<IResult> ListAsync(
string type,
string? limit,
string? after,
string? references,
IPublishedContent content,
CancellationToken cancellationToken)
{
var errors = new List<Error>();
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<IResult> GetAsync(string id, IPublishedContent content, CancellationToken cancellationToken)
{
var errors = new List<Error>();
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<Error> 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<Error> 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<string, object?>
{
["errors"] = errors.Select(error => new { error.Code, error.Message }).ToList(),
});
}

private sealed record PageResponse(IReadOnlyList<ItemResponse> Items, Guid? Next);

private sealed record ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, IReadOnlyList<UnresolvedResponse> Unresolved);

private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason);
}
4 changes: 2 additions & 2 deletions src/FluentCMS.Api/FluentCMS.Api.csproj
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\FluentCMS.Core\FluentCMS.Core.csproj" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<ProjectReference Include="..\FluentCMS.Core\FluentCMS.Core.csproj" />
</ItemGroup>

</Project>
37 changes: 37 additions & 0 deletions src/FluentCMS.Core/Delivery/IPublishedContent.cs
Original file line number Diff line number Diff line change
@@ -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<Result<PublishedPage>> ListAsync(PublishedQuery query, CancellationToken cancellationToken = default);

Task<Result<PublishedItem>> 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<PublishedItem> Items, Guid? Next);

public sealed record PublishedItem(Guid Id, string ContentType, int Version, JsonElement Fields, IReadOnlyList<UnresolvedReference> Unresolved);

public static class DeliveryErrorCodes
{
public const string LimitInvalid = "limit_invalid";
public const string ParameterInvalid = "parameter_invalid";
public const string EndpointNotFound = "endpoint_not_found";
}
1 change: 1 addition & 0 deletions src/FluentCMS.Host/FluentCmsHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public static WebApplication BuildWebApplication(string[] args, Action<WebApplic
MapStaticAssets(app);
app.MapHealthChecks("/health");
app.MapFluentCmsMcp();
app.MapFluentCmsApi();
app.MapExternalSignIn();
app.MapAuditExport();

Expand Down
116 changes: 116 additions & 0 deletions src/FluentCMS.Infrastructure/Content/EntryResolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Text.Json;
using FluentCMS.Core.Content;
using FluentCMS.Core.Schema;
using FluentCMS.Core.Sites;
using FluentCMS.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace FluentCMS.Infrastructure.Content;

// Resolves stored versions into views (CNT-04) in a fixed number of queries however many there are, so a page of the delivery API
// costs no more round trips than one entry. The references come from the reference index, never from the documents (ADR-0005).
internal static class EntryResolution
{
// Until multi-site (MST-01), every entry belongs to the one site (SCH-07).
private static readonly Guid SiteId = Site.Default.Id;

// In the order given; every version must be stored.
public static async Task<IReadOnlyList<ResolvedEntry>> ResolveAsync(
FluentCmsDbContext context,
IReadOnlyList<VersionToResolve> 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<string, JsonElement?>(StringComparer.Ordinal);
var unresolved = new List<UnresolvedReference>();

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<Dictionary<Guid, (bool Deleted, JsonElement? Fields)>> PublishedFieldsAsync(
FluentCmsDbContext context,
List<Guid> 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<JsonElement>(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);
Loading
Loading