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
20 changes: 20 additions & 0 deletions docs/api/delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ Entities are content too, so their fields hold facts as stored: each value with
| --- | --- |
| `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/entries/{id}.md` | The same entry as Markdown. |
| `GET /api/v1/index` | Every published entry with its type, title and URLs, as JSON. |
| `GET /api/v1/index.md` and `GET /llms.txt` | The same index as Markdown, grouped by content type. |

`GET /api/v1/content/{type}` takes these query parameters:

Expand All @@ -39,6 +42,7 @@ A list looks like this:
"type": "product-page",
"version": 3,
"fields": { "product": "0192f3a1-0000-7000-8000-000000000001", "price": 99, "headline": "A sturdy drill" },
"markdown": "/api/v1/entries/0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e.md",
"unresolved": []
}
],
Expand All @@ -48,6 +52,22 @@ A list looks like this:

`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.

## Markdown and the site index

Each published entry has a Markdown representation at `/api/v1/entries/{id}.md` (API-03). The URL is the entry's id, so it stays the same across versions and always shows the published version.

- Front matter gives the id, type, version and URL, as quoted strings apart from the version number.
- The heading is the entry's title: its first text field that holds text.
- Each other field follows under its name, in the order of the content type definition that the published version was written against:
- rich text as written, since it is stored as Markdown, inside a block quote so that whatever it opens ends with its field;
- other text escaped where it could open structure, which means a value can never pose as a heading, a field, a link or HTML, while words and URLs keep their characters;
- lists as bullets, each item keeping its own paragraphs, and JSON in a fenced block;
- references to whole entries as links to their Markdown.
- On an entity, every fact, the title's included, is followed by a `**Source:**` paragraph, which also says when the fact is unsourced. A source that is a web address is a link.
- References that could not be resolved are listed under "Unresolved references", each with its reason.

The site index lists every published entry for agents. `/llms.txt` serves it where agents look for it first.

## 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`:
Expand Down
73 changes: 72 additions & 1 deletion src/FluentCMS.Api/DeliveryEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using FluentCMS.Core.Agents;
using FluentCMS.Core.Content;
Expand All @@ -16,19 +17,37 @@ public static class DeliveryEndpoints
{
public const string Prefix = "/api/v1";

private const string Markdown = EntryMarkdown.MediaType + "; charset=utf-8";

public static RouteGroupBuilder MapFluentCmsApi(this IEndpointRouteBuilder endpoints)
{
var api = endpoints.MapGroup(Prefix).AllowAnonymous();
api.MapGet("/content/{type}", ListAsync);
api.MapGet("/entries/{id}", GetAsync);
api.MapGet("/entries/{id}.md", GetMarkdownAsync);
api.MapGet("/index", IndexAsync);
api.MapGet("/index.md", IndexMarkdownAsync);

// 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.")]));

// Where agents look for a site's index first, by the llms.txt convention.
endpoints.MapGet("/llms.txt", IndexMarkdownAsync).AllowAnonymous();
return api;
}

public static string EntryUrl(Guid id)
{
return $"{Prefix}/entries/{id}";
}

public static string MarkdownUrl(Guid id)
{
return $"{Prefix}/entries/{id}.md";
}

private static async Task<IResult> ListAsync(
string type,
string? limit,
Expand Down Expand Up @@ -69,6 +88,53 @@ private static async Task<IResult> GetAsync(string id, IPublishedContent content
return item.IsSuccess ? Results.Json(Item(item.Value)) : Problem(item.Errors);
}

// The URL is the entry's id, so it stays the same across versions and always shows the published one (API-03).
private static async Task<IResult> GetMarkdownAsync(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.Text(EntryMarkdown.Render(item.Value, MarkdownUrl(entryId), MarkdownUrl), Markdown)
: Problem(item.Errors);
}

private static async Task<IResult> IndexAsync(IPublishedContent content, CancellationToken cancellationToken)
{
var index = await content.IndexAsync(cancellationToken);
return Results.Json(new IndexResponse([.. index.Select(entry => new IndexEntryResponse(
entry.Id,
entry.ContentType,
entry.Version,
entry.Title,
EntryUrl(entry.Id),
MarkdownUrl(entry.Id)))]));
}

private static async Task<IResult> IndexMarkdownAsync(IPublishedContent content, CancellationToken cancellationToken)
{
var index = await content.IndexAsync(cancellationToken);
var markdown = new StringBuilder()
.Append("# Site index\n\n")
.Append(CultureInfo.InvariantCulture, $"Every published entry of this site, by content type, linked to its Markdown. The same index in JSON is at {Prefix}/index.\n");

foreach (var type in index.GroupBy(entry => (entry.ContentType, entry.ContentTypeName)))
{
markdown.Append(CultureInfo.InvariantCulture, $"\n## {EntryMarkdown.InlineText(type.Key.ContentTypeName)}\n\n");
foreach (var entry in type)
{
var title = EntryMarkdown.InlineText(entry.Title ?? $"{entry.ContentTypeName} {entry.Id}");
markdown.Append(CultureInfo.InvariantCulture, $"- [{title}]({MarkdownUrl(entry.Id)})\n");
}
}

return Results.Text(markdown.ToString(), Markdown);
}

private static Guid? Id(string? value, string name, string form, List<Error> errors)
{
if (value is null)
Expand All @@ -92,6 +158,7 @@ private static ItemResponse Item(PublishedItem item)
item.ContentType,
item.Version,
item.Fields,
MarkdownUrl(item.Id),
[.. item.Unresolved.Select(reference => new UnresolvedResponse(
reference.Path,
reference.EntityId,
Expand Down Expand Up @@ -120,7 +187,11 @@ private static IResult Problem(IReadOnlyList<Error> errors)

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 ItemResponse(Guid Id, string Type, int Version, JsonElement Fields, string Markdown, IReadOnlyList<UnresolvedResponse> Unresolved);

private sealed record IndexResponse(IReadOnlyList<IndexEntryResponse> Items);

private sealed record IndexEntryResponse(Guid Id, string Type, int Version, string? Title, string Url, string Markdown);

private sealed record UnresolvedResponse(string Path, Guid EntityId, string Field, string Reason);
}
Loading
Loading