Skip to content

api: audit against the ReSed API-design criteria — 10 findings, 2 broken published URLs #111

Description

@maehr

Summary

The talk Introduction to APIs by Peter Dängeli and Elena Spadini set out design criteria for the API of a digital scholarly edition. The slides are at https://pad.dsl.unibe.ch/resed-api-20260129. This issue records an audit of the live site against those criteria.

TextRefs meets most of them. Two published claims are false, because the URL they name returns 404. Three design choices are correct but unstated, so a reader cannot tell a decision from an omission.

Every finding below comes from a live request on 31 August 2026, first with curl and then in Chrome. The deployment served the staging branch at cd9aae3. The browser pass matters, because three behaviours depend on JavaScript or on redirect following.

What the audit confirms as strong

  • Resource design. Four record types: work, system, ref, and mapping. The talk asks for 3 to 5 core resources.
  • Granularity. One passage under one citation system. The talk names this the "meaningful scholarly unit", and warns against both a whole-file dump and a per-glyph endpoint.
  • Representations. Each record has an HTML page and a .json sibling. The head advertises <link rel="alternate" type="application/ld+json">. Verified on /id/work/homer.iliad/.
  • REST behaviour. GET and HEAD return 200. POST /reg/works.json returns 405. The site is stateless.
  • Caching. The host sends etag, last-modified, and cache-control: max-age=600. A request with If-None-Match returns 304.
  • Browser clients. access-control-allow-origin: * on every JSON response.
  • Access control. No authentication, which the talk endorses for public-domain content.
  • Linked Data without SPARQL. JSON-LD with a context at /contexts/v1.jsonld. The talk recommends this trade-off for a small team.
  • Bulk access. /dump/ serves four NDJSON files, one alias table, and a Frictionless datapackage.json with a byte count and a sha256: hash per resource. The talk does not ask for this.
  • Honest documentation. api/openapi.yaml:32-39 records that a .jsonl body arrives as application/octet-stream, and tells clients to parse by shape.
  • A working API explorer. /find/ resolved the query Plato Republic 514a to the work, two Perseus editions, a citation to copy, and a draft warning. It writes ?q= into the URL, so a result is linkable. It queries the same public endpoints any other client uses. The talk says an explorer "lowers the barrier to entry dramatically", and TextRefs has one.
  • Short aliases resolve in a browser. /cite/homer.iliad/1.1 landed on the canonical record page. Verified in Chrome.

Status

Five of the ten findings are fixed and merged into staging (2026-08-31). The other five stay
open here, and each needs a decision before anyone writes code or prose.

Finding State Where
G1 — OpenAPI file not published fixed 5914e26 (#117, closes #112)
G2 — /cite/ and /find/ absent from the contract open needs a decision
G3 — declared 404 body ≠ served body fixed 5914e26 (#117, closes #113)
G4 — no robots.txt, no stated limit fixed cf3a416 (#119, closes #114)
G5 — published JSON Schema does not exist fixed 0607ec6 (#118, closes #115)
G6 — no stated HTTP versioning policy open needs a decision
G7 — no query parameters, choice unstated open needs prose in /api/
G8 — /dump/ has no index page fixed cf3a416 (#119, closes #116)
G9 — German alternate on record pages is broken open Starlight behaviour
G10 — production 404 page carries a draft banner open needs a component override

The five fixes are on staging only. They reach the live site when staging deploys, and they
reach main with the release in #4. Re-run the curl block below after that deploy: the four
404s for G1, G4, G5 and G8 must become 200.

Findings

Ordered by severity.

G1 — The OpenAPI file is not published (high) — fixed

Fixed in 5914e26 (#117), which closes #112.

astro.config.mjs:55 advertises "OpenAPI at /api/openapi.yaml" in the site description. GET /api/openapi.yaml returns 404.

starlight-openapi reads api/openapi.yaml at build time and renders HTML at /api/. Nothing copies the source file into the output. find dist -name "*openapi*" returns no result, and no script in package.json or scripts/ copies it.

A client can therefore read the docs, but cannot generate code, run contract tests, or import the API into a tool such as Hoppscotch. The talk names OpenAPI as the industry standard, and values it because tools consume the file.

The site makes a claim that the site does not meet.

G2 — /cite/ and /find/ are absent from the contract (high)

api/openapi.yaml has no path item for /cite/{work_key}/{locator} or /cite/{work_key}/{system_key}/{locator}. AGENTS.md:28 describes both. /find/ is also undocumented, although it is the browser tool that the talk calls an "API explorer".

The live behaviour also differs from the word "redirect" in the site description. GET /cite/homer.iliad/1.1 returns 301 to the trailing-slash form. That URL returns 200 with an HTML meta refresh, not a 303.

A browser follows the meta refresh correctly. Chrome landed on /id/ref/cf079227-2d9a-5734-aa05-efd6777d7220/. A client that follows HTTP redirects only stops at the meta-refresh page and never reaches the record. The defect therefore affects scripts, crawlers, and reference managers, not people.

The talk asks for a "complete list with descriptions". The undocumented surface is the one a scholar pastes into a footnote.

G3 — The declared 404 body does not match the served body (medium) — fixed

Fixed in 5914e26 (#117), which closes #113.

Every record path in api/openapi.yaml declares 404 as application/json against the Error schema at api/openapi.yaml:594-599. GET /id/work/does-not-exist.json returns 404 with content-type: text/html and the Starlight 404 page.

A static host cannot produce a per-path JSON error. Declare what the host sends, and either remove the Error schema or mark it reserved.

G4 — No robots.txt and no stated limit (medium) — fixed

Fixed in cf3a416 (#119), which closes #114.

GET /robots.txt returns 404. The talk's access-control slide states that one misconfigured crawler can overwhelm a small project, and that a rate limit now protects availability.

TextRefs inherits the GitHub Pages soft limits. /dump/aliases.json is 2.5 MB and /dump/references.jsonl is 1.7 MB. Nothing tells a crawler to prefer /dump/ over the thousands of record pages, and nothing tells a heavy consumer what the limits are.

G5 — The published JSON Schema does not exist (medium) — fixed

Fixed in 0607ec6 (#118), which closes #115.

src/content/docs/standard/specification.md:402 states that a normative JSON Schema "is published at https://textrefs.org/schemas/v1/textrefs.schema.json". That URL returns 404, and so does /schemas/. ROADMAP.md lists the same path under "Next" as planned.

The specification states as fact what the roadmap states as planned. zod version 4 is already a dependency and can emit JSON Schema, so either publish the file or correct the tense.

G10 — The production 404 page carries a draft banner (medium)

Every 404 page shows a yellow aside that reads "This content is a draft and will not be included in production builds." The banner is live on https://textrefs.org, so the statement contradicts itself.

The cause is src/content/docs/404.mdx:6, which sets draft: true. Starlight keeps the 404 route in the production build through a direct getEntry() lookup, and renders the draft notice with it.

Correction. An earlier version of this issue called this a one-line fix. That is wrong. The draft: true flag is load-bearing. Commit 666dc78 added it on purpose, to stop Starlight rendering /404 twice: once through its dedicated route and once through the [...slug] catch-all, which produced a build warning. Removing the flag brings the warning back and re-adds /de/404/.

The fix must therefore keep the flag and suppress the banner for this route, through a component override. Alternatively, move the custom 404 out of the docs collection.

The talk's error-handling slide states that an error message is part of the user experience of an API. This banner tells a visitor that a working production page is unpublished.

This finding needed the browser. A curl check of the status code alone does not show it.

G6 — No stated versioning policy for the HTTP surface (low, rising)

versioning.md versions the standard, the registry data, and the data package. The HTTP paths carry no version. Only the JSON-LD context does, at /contexts/v1.jsonld.

The talk lists three strategies: URL versioning, header versioning, and "only add, never break". TextRefs appears to follow the third, and ADR-0004 supports it. The talk's point is that an unstated policy is the sustainability risk, because a consumer cannot plan against it.

Severity rises at the first active record.

G7 — No query parameters, and the choice is unstated (low)

The talk calls pagination "essential for large result sets", and lists limit, page, sort, format, and fields. No TextRefs endpoint supports any of them, because a static host cannot process a query string.

One exception exists. /find/?q= is a real query parameter, but the browser reads it, not the host. It is undocumented.

This is the correct trade-off. The talk's own conclusion is that a simple and maintained API beats a complex and abandoned one, and a static site survives the departure of its developer. TextRefs already covers the need three ways: /reg/work/{key}/refs/{page}/ paginates as HTML, the JSON collections are small enough to fetch whole, and /dump/ serves the bulk consumer.

Two payloads are large. /reg/work/new-testament/aliases.json is 406 KB and /dump/aliases.json is 2.5 MB. Both are cacheable and both support 304.

The gap is not the missing parameters. The gap is that /api/ never states the choice. A reader who arrives from the talk expects limit and page, finds nothing, and cannot tell a decision from unfinished work.

G8 — /dump/ has no index page (low) — fixed

Fixed in cf3a416 (#119), which closes #116.

/reg/ returns a browsable index. GET /dump/ returns 404. A reader who removes the filename from a dump URL finds nothing, and datapackage.json is an entry point that a reader cannot guess.

G9 — The German alternate on record pages is broken (low)

Record pages carry <link rel="alternate" hreflang="de" href="https://textrefs.org/de/id/work/homer.iliad/">. That URL returns 404. AGENTS.md:44-46 limits the German mirror to Association pages, so no German record page exists.

This finding is outside the talk's scope. It was found during the same probe. The effect is on search engines only.

The largest open question

All 23 works have status: draft. Under ADR-0004 a draft identifier carries no persistence promise. The talk asks whether a design survives two years, five years, and the departure of its first developer. The API shape survives that test. The promotion of records from draft to active is the step that turns a well-shaped API into a citable one, and no finding above changes that.

How to reproduce

With curl:

curl -s -o /dev/null -w "%{http_code}\n" https://textrefs.org/api/openapi.yaml                    # 404 — G1
curl -s -o /dev/null -w "%{http_code}\n" https://textrefs.org/schemas/v1/textrefs.schema.json     # 404 — G5
curl -s -o /dev/null -w "%{http_code}\n" https://textrefs.org/robots.txt                          # 404 — G4
curl -s -o /dev/null -w "%{http_code}\n" https://textrefs.org/dump/                               # 404 — G8
curl -s -o /dev/null -w "%{http_code}\n" https://textrefs.org/de/id/work/homer.iliad/             # 404 — G9
curl -s https://textrefs.org/cite/homer.iliad/1.1/ | head -c 200                                  # meta refresh — G2
curl -s -o /dev/null -w "%{http_code}\n" https://textrefs.org/id/work/does-not-exist.json         # 404 as HTML — G3
npm run build && find dist -name "*openapi*"                                                      # no result — G1

In a browser, because curl cannot show these three:

  1. Open https://textrefs.org/cite/homer.iliad/1.1. The page lands on the record. G2 affects non-browser clients only.
  2. Open https://textrefs.org/find/ and type Plato Republic 514a. The result appears, and the URL gains ?q=. This is the undocumented explorer in G7.
  3. Open any missing URL, for example https://textrefs.org/robots.txt. Read the yellow aside on the 404 page. This is G10.

Suggested split

This issue is the tracker. Five findings have sub-issues:

G1 and G5 were the two false published claims, so they came first. All five landed in three PRs
on 2026-08-31, in the order #117 → #119 → #118.

Two review findings on those PRs are worth recording here, because both were substantive:

  1. The generated JSON Schema first emitted format: "uri" for z.url(). RFC 3986 forbids
    non-ASCII, and 1219 references carry a resolver target such as
    …/Investigaciones_filosóficas_(edición_A)#76. The document declares format: "iri"
    instead, and all 86477 records validate.
  2. The same document dropped the §6 rules on alternative_labels. uniqueItems now travels
    from standard/schema/work.ts into the published document, and the half that compares two
    members — no entry repeats preferred_label — is stated in the field description, because
    no keyword expresses it.

The other findings stay here for now. G2, G6, and G7 need a decision before anyone writes prose. G9 and G10 are both Starlight behaviour, and neither is a one-line change.

Activity

  1. added
    bugSomething isn't working
    documentationImprovements or additions to documentation
    post-v0.1.0Deferred past the v0.1.0 baseline. Revisit if the need arises.
    and removed
    triageNeeds maintainer triage
    on Sep 1, 2026
  2. maehr commented on Sep 1, 2026

    @maehr
    MemberAuthor

    G10 is split out and ships in v0.1.0. PR #126 targets staging.

    The cause was not a component override. src/content/docs/404.mdx carried draft: true, and Starlight renders DraftContentNotice from that flag alone. Removing the flag also required one edit to the sitemap filter, because the localized /de/404/ alias then entered the sitemap. The status table above can be updated when #126 merges.

    This issue keeps the post-v0.1.0 label for the four findings that remain open: G2 (/cite/ and /find/ absent from the contract), G6 (no stated HTTP versioning policy), G7 (no query parameters, choice unstated), and G9 (German alternate on record pages is broken).

    G10 was pulled into the release on the same ground as #3 and #99: the tag freezes a false published claim, and the claim is live on https://textrefs.org today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentationpost-v0.1.0Deferred past the v0.1.0 baseline. Revisit if the need arises.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions