feat: updated integration page with missing API routes

This commit is contained in:
Florian Sylvain committed 2026-10-26 02:18:35 +01:00
1 parent 25d9c30b8a
commit 7c0ee21906
25 files changed
+2402 -350

No files matched your search

+40 -4
View File
@@ -15,7 +15,7 @@ Sticky section index on the right (desktop), "Jump to" select (mobile). A refere
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys". - Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeSnippet tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified." - Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header." - Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."" - Groups, in index order: Authentication, Articles (7), Search and sharing (2), Content types (5), Entries (5), Built-in types (2), Media (10), Sites and authors (2), Images and variants, Users and invites (6), Settings (1), Session only (35 routes in 6 sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants, Activity; every `/v1/system` route is in this group), Errors, Notes. Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`). - Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. The 403 row has a note line under it: "Site SEO defaults are owner only. Article SEO fields need only the right to edit the article." (12/18, `--ui-text-muted`, in the cause column, 2 px under the cause text). It is the only row with a note; rows below it move down by the wrapped note height (two 18 px lines on desktop, three on mobile, plus 2 px). Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default." - Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
@@ -139,10 +139,10 @@ Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×8
|---|---|---| |---|---|---|
| 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" | | 8a | Trigger focused | Placeholder "Search the API reference…"; key hint "/"; aria-label "Search the API reference" |
| 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" | | 8b | Open, empty | Input placeholder "Search the API reference…"; group "Suggested": Authentication ("Public access and API keys"), POST /v1/media/ ("Upload an image (multipart)." Verified user), Errors ("Status codes and messages"), GET /v1/article/ ("List articles, one page at a time." Public), PATCH /v1/user/me ("Update my profile." Verified user); footer "↑↓ Move", "↵ Open", "Esc Close", count "5 suggestions"; live "5 suggestions" |
| 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "6 routes"); count and live "7 results" | | 8c | Typing "media" | Groups "Endpoints" (6 media routes) and "Sections" (Media, "10 routes"); count and live "17 results, showing 7" |
| 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "6 more endpoints. Keep typing to narrow the list."; count "13 results"; live "13 results, showing 7" | | 8d | "404", third row selected | Endpoints list the six first 404 routes with an "Error" line, for example "404 Article not found"; Sections: Errors ("Status codes and messages"); "N more endpoints. Keep typing to narrow the list."; count "22 results"; live "22 results, showing 7" |
| 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" | | 8e | "keep_id" | POST /v1/media/merge, "Merge identical copies into one image.", Verified user, line "Body field keep_id"; "1 result" |
| 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "12 routes, not available to API keys"; "2 results" | | 8f | "login" | POST /v1/auth/login, "Log in", chip "Session only" (lock); Section "Session only", "35 routes, not available to API keys"; "2 results" |
| 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" | | 8g | No results | No match for "xyz". Try a path, a method or a status code like 404. Count "0 results"; live "No results" |
| 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" | | 8h | After selecting | Page only: POST /v1/media/merge expanded and highlighted; index and Jump to read "Media" |
@@ -162,3 +162,39 @@ Board: group "Integration", states 8a to 8h, desktop 1440×900 and mobile 390×8
- The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index? - The search trigger scrolls away with the header on desktop. Open question: keep it sticky next to the section index?
- Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed. - Highlight uses --rc-sand on --rc-on-sand. On the selected row (--ui-bg-elevated) the two tones are close; the dark text keeps contrast. No new token is needed.
- The 1.5 s flash duration and the #ep-<id> hash are proposals. - The 1.5 s flash duration and the #ep-<id> hash are proposals.
## Custom content types round
Board: group "Integration", states 9a to 9j. Source: `IntegrationPage.dc.html`, `IntegrationSearch.dc.html`. Every route an API key can call is now documented.
### New groups
- Content types (`ctypes`): description "Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes." A "Field types" table (Type, Value in an entry, Rules) and a sample field object sit above the routes (anchor `ct-fields`).
- `GET /v1/content-type/` and `GET /v1/content-type/{key}`: Verified user. 404 `content_type_not_found`.
- `POST /v1/content-type/`: Owner or admin. Body `key` (2 to 40, lowercase letters, digits, hyphens), `name` (max 100), `icon` (optional), `fields` (1 to 50). 409 `content_type_key_taken`.
- `PUT /v1/content-type/{key}`: Owner or admin. Body `name`, `icon`, `fields`. 409 `schema_incompatible`.
- `DELETE /v1/content-type/{key}`: Owner or admin. 204. 409 `content_type_has_entries`, 409 `content_type_referenced`.
- Entries (`entries`): `GET` and `POST /v1/content-type/{key}/entry`, `GET`, `PUT` and `DELETE /v1/content-type/{key}/entry/{id}`. Verified user. List query `page`, `size` (5, 25, 50, 100), `sort` (`id`, `created_at`, `updated_at`, then `:asc` or `:desc`); the response is `{ items, total, page, size }`, not a bare array. Body `data` (object keyed by field key). `media` in a response is keyed by field key. 409 and 422 bodies are both `invalid_entry: <reason>`; 409 only when a unique field clashes. Id 400: "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32)."
- Built-in types (`builtin`): `GET /v1/builtin-type/` and `/{key}`, Owner or admin. 404 `builtin_type_not_found`.
- Sites and authors (`sites`): `GET /v1/site/` and `GET /v1/author/` (paged, `{ items, total, page, size }`), Verified user.
- Media adds `GET` and `PATCH /v1/media/purge-settings` (`auto_purge_enabled`, `auto_purge_interval_days` 1 to 365; response also `last_purge_at`, `last_purge_merged`), `GET /v1/media/purge-preview` and `POST /v1/media/purge` (optional `fingerprint` from the preview). Owner or admin.
- PUT uses the PATCH badge colours.
### Access and errors
- 403 rows: "Only owners and admins can do this." (owner-or-admin routes) and "Only the owner can do this.", both listed in the 403 row; the existing note stays.
- 404 adds `content_type_not_found`, `entry_not_found`, `builtin_type_not_found`. 409 and 422 are real rows now (earlier text said there is no such row). Code-style text is the literal body.
- Authentication copy says a key can also manage content types and entries, and owner or admin routes answer 403 to a key whose creator is neither.
### Search
The index covers the new endpoints, body fields (`data`, and the events sample keys such as `capacity`) and error codes. "entry" also matches "entries". "N more endpoints" is singular for one.
### States 9a to 9j
9a create a content type, curl tab · 9b entries list, Response tab · 9c entry replace with the 409 and 422 errors · 9d Errors table · 9e Session only, 6 sub-groups · 9f search "entry" · 9g search "capacity" (Body field) · 9h search "schema_incompatible" (Error) · 9i search "content type" (Sections) · 9j navigation with scroll-spy on Content types. 8c now announces "17 results, showing 7" and 8d "22 results, showing 7" (the copy table above predates this; the board is right).
### Final round
- `POST /v1/media/purge` and the built-in type samples now match the API (no `icon`; keys `article`, `author`, `site`, `media`, `user`). Built-in types are read-only through the API.
- `GET /v1/media/` takes `page` (default 1) and `size` (5, 25, 50, 100; default 25), returns a JSON array and sends the total in `X-Total-Count`; 400 "Invalid page or size."
- Counts: Articles 7, Search and sharing 2, Content types 5, Entries 5, Built-in types 2, Media 10, Sites and authors 2, Users and invites 6, Settings 1, Session only 35. Search index: 75 endpoints (40 documented, 35 session-only).
- Announced: 8c "17 results, showing 7", 8d "22 results, showing 7", 8f "2 results", 9f "8 results, showing 7", 9g "2 results", 9h "1 result", 9i "12 results, showing 8".
- Illustrative only: built-in sample fields, purge-preview sample, the reason after `invalid_entry:`, session-only row labels, entry `created_by`/`updated_by`, `cover` media data.
- Not confirmed by the design tool: dark mode, the search palette on mobile and the Session only list on mobile. `task visual:check` is the gate.
- `POST /v1/content-type/` and `POST …/entry` answer 201; POST cards show only the JSON body.
+73 -23
View File
@@ -28,7 +28,7 @@
</div> </div>
<sc-if value="{{ desk }}"><button aria-label="Search the API reference" aria-keyshortcuts="/ Control+K Meta+K" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;width:320px;flex-shrink:0;height:36px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);outline:{{ trigRing }};outline-offset:2px" style-hover="border-color:var(--ui-text-muted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:14px">Search the API reference…</span><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">/</span></button></sc-if> <sc-if value="{{ desk }}"><button aria-label="Search the API reference" aria-keyshortcuts="/ Control+K Meta+K" aria-haspopup="dialog" style="all:unset;box-sizing:border-box;cursor:pointer;width:320px;flex-shrink:0;height:36px;padding:0 8px 0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);outline:{{ trigRing }};outline-offset:2px" style-hover="border-color:var(--ui-text-muted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/search.svg') center / contain no-repeat"></span><span style="flex:1;min-width:0;color:var(--ui-text-muted);font-size:14px">Search the API reference…</span><span aria-hidden="true" style="height:20px;min-width:20px;padding:0 6px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">/</span></button></sc-if>
</div> </div>
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.</p> <p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.</p>
<div style="display:flex;flex-direction:column;gap:6px"> <div style="display:flex;flex-direction:column;gap:6px">
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span> <span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"> <div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.</span></div>
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div> <div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div>
</div> </div>
</section> </section>
@@ -61,6 +61,11 @@
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px"> <section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div> <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div>
<span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span> <span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span>
<sc-if value="{{ g.hasNote }}"><div style="display:flex;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty"><sc-for list="{{ g.noteParts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></div></sc-if>
<sc-if value="{{ g.hasFt }}"><div data-anchor="{{ g.ftA }}" style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Field types</span><div style="border:1px solid var(--ui-border)">
<div style="display:grid;grid-template-columns:{{ g.ftCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Type</span><span>Value in an entry</span><sc-if value="{{ desk }}"><span>Rules</span></sc-if></div>
<sc-for list="{{ g.ftRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ g.ftCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.t }}</span><span style="color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.v }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><span style="grid-column:{{ g.ftRule }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.r }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span></div></sc-for></div></div></sc-if>
<sc-if value="{{ g.hasSnip }}"><dc-import name="CodeSnippet" tabs="{{ g.snip }}" hint-size="100%,160px"></dc-import></sc-if>
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)"> <div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6"> <sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)"> <div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
@@ -77,9 +82,9 @@
<div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div> <div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div>
<sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if> <sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if>
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div> <sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
<dc-import name="CodeSnippet" tabs="{{ ep.snips }}" hint-size="100%,200px"></dc-import> <dc-import name="CodeSnippet" tabs="{{ ep.snips }}" active="{{ ep.tabSet }}" hint-size="100%,200px"></dc-import>
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span> <div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code></span></sc-if></div></sc-for></div> <sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><sc-if value="{{ e.plain }}"><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></sc-if><sc-if value="{{ e.code }}"><span style="min-width:0;color:var(--ui-text-toned)"><code>{{ e.cap }}</code><sc-if value="{{ e.hasTail }}"><span style="margin-left:6px">{{ e.tail }}</span></sc-if></span></sc-if></div></sc-for></div>
</div> </div>
</sc-if> </sc-if>
</div> </div>
@@ -89,7 +94,7 @@
</sc-for> </sc-for>
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px"> <section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">12 routes, not available to API keys</span></div> <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">{{ sessCount }}</span></div>
<div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)"> <div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)">
<div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div> <div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div>
<sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3"> <sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3">
@@ -108,7 +113,7 @@
<span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span> <span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden"> <div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div> <div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span><sc-if value="{{ r.n }}"><span style="grid-column:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for> <sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty"><sc-for list="{{ r.parts }}" as="p" hint-placeholder-count="2"><sc-if value="{{ p.c }}"><code>{{ p.t }}</code></sc-if><sc-if value="{{ p.p }}">{{ p.t }}</sc-if></sc-for></span><sc-if value="{{ r.n }}"><span style="grid-column:{{ errNoteCol }};color:var(--ui-text-muted);font-size:12px;line-height:18px;text-wrap:pretty">{{ r.n }}</span></sc-if></div></sc-for>
</div> </div>
<dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import> <dc-import name="CodeSnippet" tabs="{{ errSnips }}" hint-size="100%,120px"></dc-import>
</section> </section>
@@ -134,11 +139,16 @@
</x-dc> </x-dc>
<script type="text/x-dc" data-dc-script> <script type="text/x-dc" data-dc-script>
class Component extends DCLogic { class Component extends DCLogic {
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl', flash: i.flash || null } })() state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: this.grpOf(i.scroll || 'auth'), authTab: i.authTab || 'curl', flash: i.flash || null } })()
rootRef = React.createRef() rootRef = React.createRef()
BASE = 'https://cms.renew.example' BASE = 'https://cms.renew.example'
KEY = 'rcms_Kf3x9QwL0aBc…' KEY = 'rcms_Kf3x9QwL0aBc…'
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['seo', 'Search and sharing'], ['media', 'Media'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']] IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['seo', 'Search and sharing'], ['ctypes', 'Content types'], ['entries', 'Entries'], ['builtin', 'Built-in types'], ['media', 'Media'], ['sites', 'Sites and authors'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
grpOf(s) {
if (s.indexOf('ep-') !== 0 && s !== 'ct-fields') return s
const id = s.replace('ep-', ''), M = [['ct-', 'ctypes'], ['en-', 'entries'], ['bt-', 'builtin'], ['med-', 'media'], ['site-', 'sites'], ['author-', 'sites'], ['art-', 'articles'], ['seo-', 'seo'], ['sitemap', 'seo'], ['usr-', 'users'], ['auth-', 'users'], ['set-', 'settings']]
const m = M.find(x => id.indexOf(x[0]) === 0); return m ? m[1] : 'auth'
}
componentDidMount() { componentDidMount() {
const i = (this.props.s && this.props.s.int) || {} const i = (this.props.s && this.props.s.int) || {}
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) } const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
@@ -154,8 +164,8 @@ class Component extends DCLogic {
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) } yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
onScroll = () => { onScroll = () => {
const r = this.rootRef.current; if (!r) return const r = this.rootRef.current; if (!r) return
let cur = this.IDX[0][0] let cur = this.IDX[0][0], best = -1
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k } for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (!el) continue; const y = this.yOf(el, r); if (y <= r.scrollTop + 120 && y > best) { best = y; cur = k } }
if (cur !== this.state.active) this.setState({ active: cur }) if (cur !== this.state.active) this.setState({ active: cur })
} }
flash(k) { flash(k) {
@@ -181,6 +191,16 @@ class Component extends DCLogic {
const i404 = [404, 'Invite not found'] const i404 = [404, 'Invite not found']
const mm = 'The request cannot be processed due to a mismatch in the format of the body.' const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
const j = o => JSON.stringify(o, null, 2) const j = o => JSON.stringify(o, null, 2)
const eAd = [403, 'Only owners and admins can do this.'], ctnf = [404, 'content_type_not_found', true], enf = [404, 'entry_not_found', true]
const Pp = s => s.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t)
const F = (key, label, type, o = {}) => ({ key, label, type, required: false, unique: false, default: null, min: null, max: null, options: null, relation: null, ...o })
const evF = [F('title', 'Title', 'text', { required: true, min: 3, max: 120 }), F('starts_on', 'Starts on', 'date', { required: true }), F('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }), F('online', 'Online', 'boolean'), F('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }), F('cover', 'Cover', 'media')]
const evT = { id: 2, key: 'events', name: 'Events', icon: 'calendar', fields: evF, created_at: '2026-09-02T08:30:00Z', updated_at: '2026-10-20T14:05:00Z' }
const evD = { title: 'Open day', starts_on: '2026-11-14', capacity: 120, online: false, category: 'Open day', cover: 4 }
const ent = { id: 31, content_type: 'events', data: evD, created_by: 1, updated_by: 1, created_at: '2026-10-20T14:05:00Z', updated_at: '2026-10-22T09:41:00Z', media: { cover: { id: 4, filename: 'north-wall.jpg', width: 1600, height: 1067, size: 3900000, thumbnail_url: '/static/uploadedImages/3f9c-thumb.jpg' } }, relations: {} }
const FT = [['text', 'string', 'min/max = length in characters. Can be unique.'], ['richtext', 'string (Markdown)', 'min/max = length. Cannot be unique.'], ['number', 'number', 'min/max = value. Can be unique.'], ['boolean', 'true/false', 'No min/max. Cannot be unique.'], ['date', 'string "YYYY-MM-DD"', 'min/max = earliest/latest date. Can be unique.'], ['select', 'one string from `options` (1 to 50 choices)', 'Can be unique.'], ['media', 'the numeric id of an uploaded image (see Media)', '\u2014'], ['relation', 'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many', '\u2014']]
const btA = [F('title', 'Title', 'text', { required: true, min: 3, max: 200 }), F('slug', 'Slug', 'text', { required: true, unique: true }), F('body', 'Body', 'richtext')], btU = [F('name', 'Name', 'text', { required: true, min: 1, max: 100 }), F('slug', 'Slug', 'text', { required: true, unique: true })]
const nameRule = 'Up to 100 characters.', fieldsRule = '1 to 50 fields, in order. See Field types.'
const G = { const G = {
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [ articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }), A('art-list', 'GET', '/v1/article/', 'List articles, one page at a time. Anonymous callers get online articles only, and the total counts only those. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Public', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1. A page past the end returns an empty array.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
@@ -195,13 +215,39 @@ class Component extends DCLogic {
A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }), A('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article: title (with the site template applied), description, canonical URL, robots, Open Graph and Twitter values, image with alt, authors, dates and ready-made Article JSON-LD. Every empty field already holds its fallback. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ title: 'How we price a bathroom renovation | Renew', description: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation', robots: 'index, follow', og: { type: 'article', title: 'How we price a bathroom renovation | Renew', image: 'https://renew.example/media/north-wall.jpg', image_alt: 'A tiled bathroom wall', site_name: 'Renew' }, twitter: { card: 'summary_large_image' }, authors: [{ name: 'Inès Duarte', url: 'https://ines-duarte.example', same_as: [] }], published_at: '2026-03-12T09:14:00Z', modified_at: '2026-10-15T11:02:00Z', json_ld: { '@type': 'Article' } }) }),
A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) }) A('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site, each with its last-modified date and a noindex flag, one page at a time. Use it to build your sitemap.xml. The total is in the X-Total-Count response header.', 'Public', 1, [[400, mm], e401, [404, 'site_not_found', true]], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 25, 50, 100 or 500. Default 100.')], { qp: 1, pub: 1, r: j([{ url: 'https://renew.example/how-we-price-a-bathroom-renovation', modified_at: '2026-10-15T11:02:00Z', noindex: false }]) })
] }, ] },
ctypes: { title: 'Content types', desc: 'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.', note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.', ft: 1, snip: [{ k: 'field', label: 'Field object', code: j(evF[2]) }], eps: [
A('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', 1, [e401], [], { r: j([{ ...evT, fields: evF.slice(0, 3) }]) }),
A('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', 1, [e401, ctnf], [], { r: j(evT) }),
A('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', 'Owner or admin', 1, [[400, mm], e401, eAd, [409, 'content_type_key_taken', true]], [S('key', 'string', 'Yes', '2 to 40 characters. Lowercase letters, digits and hyphens. Can\'t be changed later.'), S('name', 'string', 'Yes', 'Up to 100 characters.'), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { key: 'events', name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
A('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change.', 'Owner or admin', 1, [[400, mm], e401, eAd, ctnf, [409, 'schema_incompatible', true, 'Some existing entries don\'t fit the new fields. Nothing was changed.']], [S('name', 'string', 'Yes', nameRule), S('icon', 'string', 'No', 'Optional.'), S('fields', 'object[]', 'Yes', fieldsRule)], { pretty: 1, body: { name: 'Events', icon: 'calendar', fields: evF.slice(0, 3) }, r: j(evT) }),
A('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it.', 'Owner or admin', 1, [e401, eAd, ctnf, [409, 'content_type_has_entries', true], [409, 'content_type_referenced', true]], [], { r: '204 No Content' })
] },
entries: { title: 'Entries', desc: 'Read and write the content of a custom type. Any verified user can write entries.', note: 'An entry\'s `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).', snip: [{ k: 'entry', label: 'Entry', code: j(ent) }], eps: [
A('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', 1, [[400, 'Invalid page, size or sort.'], e401, ctnf], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.'), S('sort', 'string', 'No', 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.')], { qp: 1, qs: '?page=1&size=25&sort=updated_at:desc', r: j({ items: [ent], total: 1, page: 1, size: 25 }) }),
A('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover'], [[400, mm], e401, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: evD }, r: j(ent) }),
A('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', 1, [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], e401, enf, ctnf], [], { r: j(ent) }),
A('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', 1, [[400, mm], e401, enf, ctnf, [409, 'invalid_entry: title must be unique', true], [422, 'invalid_entry: capacity must be at most 5000', true]], [S('data', 'object', 'Yes', 'Keys are the type\'s field keys. For events: title, starts_on, capacity, online, category and cover.')], { body: { data: { ...evD, capacity: 150 } }, r: j({ ...ent, data: { ...evD, capacity: 150 } }) }),
A('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', 1, [e401, enf, ctnf], [], { r: '204 No Content' })
] },
builtin: { title: 'Built-in types', desc: 'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.', note: 'Built-in types are read-only: they cannot be changed through the API.', eps: [
A('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', 'Owner or admin', 1, [e401, eAd], [], { r: j([{ key: 'article', name: 'Articles', fields: btA }, { key: 'author', name: 'Authors', fields: btU }]) }),
A('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'Owner or admin', 1, [e401, eAd, [404, 'builtin_type_not_found', true]], [], { r: j({ key: 'author', name: 'Authors', fields: btU }) })
] },
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [ media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }), A('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }), A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }), A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }), A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }), A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }) A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
A('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', 'Owner or admin', 1, [e401, eAd], [], { r: j({ auto_purge_enabled: true, auto_purge_interval_days: 30, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
A('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Owner or admin', 1, [[400, mm], e401, eAd], [S('auto_purge_enabled', 'boolean', 'No', 'Turns the automatic purge on or off.'), S('auto_purge_interval_days', 'integer', 'No', '1 to 365. Days between automatic purges.')], { body: { auto_purge_enabled: true, auto_purge_interval_days: 14 }, r: j({ auto_purge_enabled: true, auto_purge_interval_days: 14, last_purge_at: '2026-10-20T03:00:00Z', last_purge_merged: 4 }) }),
A('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete.', 'Owner or admin', 1, [e401, eAd], [], { r: j({ fingerprint: 'c41f9a7e', count: 2, items: [{ id: 9, filename: 'old-wall.jpg' }, { id: 11, filename: 'old-wall-2.jpg' }] }) }),
A('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged.', 'Owner or admin', 1, [e401, eAd], [S('fingerprint', 'string', 'No', 'Optional. Taken from purge-preview.')], { body: { fingerprint: 'c41f9a7e' }, r: '{ "groups_total": 3, "groups_purged": 2, "merged": 2, "articles_updated": 5, "sites_updated": 0, "authors_updated": 1, "failed_groups": 1 }' })
] },
sites: { title: 'Sites and authors', desc: 'Read the sites and authors your content belongs to. Creating and changing them needs a session.', eps: [
A('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', 1, [e401], [], { r: j([{ id: 1, key: 'renew', name: 'Renew', default: true }]) }),
A('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', 1, [[400, 'Invalid page or size.'], e401], [S('page', 'integer', 'No', '1 or higher. Default 1.'), S('size', 'integer', 'No', 'One of 5, 25, 50 or 100. Default 25.')], { qp: 1, r: j({ items: [{ id: 3, name: 'In\u00e8s Duarte', slug: 'ines-duarte' }], total: 1, page: 1, size: 25 }) })
] }, ] },
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [ users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }), A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
@@ -215,35 +261,35 @@ class Component extends DCLogic {
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) }) A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
] } ] }
} }
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] } const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], PUT: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const mob = !!this.props.mobile const mob = !!this.props.mobile
return Object.keys(G).map(key => { return Object.keys(G).map(key => {
const g = G[key] const g = G[key]
return { key, title: g.title, desc: g.desc, count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => { return { key, title: g.title, desc: g.desc, hasNote: !!g.note, noteParts: g.note ? Pp(g.note) : [], hasFt: !!g.ft, ftA: g.ft ? 'ct-fields' : '', ftCols: mob ? '64px minmax(0,1fr)' : '88px minmax(0,1.3fr) minmax(0,1fr)', ftRule: mob ? '2' : 'auto', ftRows: g.ft ? FT.map(r => ({ t: r[0], v: Pp(r[1]), r: Pp(r[2]) })) : [], hasSnip: !!g.snip, snip: g.snip || [], count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12') const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{key}', e.p.indexOf('/v1/content-type') === 0 ? 'events' : e.p.indexOf('/v1/builtin-type') === 0 ? 'author' : 'renew').replace('{id}', '12') + (e.qs || '')
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY] const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"' let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
hdr.forEach(h => curl += ' \\\n -H "' + h + '"') hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + JSON.stringify(e.body) + '\'' if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + (e.pretty ? JSON.stringify(e.body, null, 2) : JSON.stringify(e.body)) + '\''
if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"') if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"')
let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'" let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'"
const opts = [] const opts = []
if (e.m !== 'GET') opts.push("method: '" + e.m + "'") if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }') if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }')
if (e.body) opts.push('body: JSON.stringify(' + JSON.stringify(e.body) + ')') if (e.body) opts.push('body: JSON.stringify(' + (e.pretty ? JSON.stringify(e.body, null, 2).replace(/\n/g, '\n ') : JSON.stringify(e.body)) + ')')
if (e.form) opts.push('body: form') if (e.form) opts.push('body: form')
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')' fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON' const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
const cp = this.state.copied === e.id const cp = this.state.copied === e.id
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o }) const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })] const chips = [chip(e.acc, e.acc === 'Owner only' || e.acc === 'Owner or admin' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips, return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: this.state.flash === e.id ? 'var(--rc-sand)' : open ? 'var(--ui-bg-muted)' : 'transparent', ring: this.state.flash === e.id ? '2px solid var(--ui-primary)' : 'none', label: e.m + ' ' + e.p + '. ' + e.d, chips,
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })), toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields, bodyTitle: e.qp ? 'Query parameters' : e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }), tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }], snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }],
errs: e.errs.map(([c, cap, code]) => ({ c, cap, code: !!code, plain: !code })) } errs: e.errs.map(([c, cap, code, tail]) => ({ c, cap, code: !!code, plain: !code, hasTail: !!tail, tail })), tabSet: this.state.tab[e.id] }
}) } }) }
}) })
} }
@@ -259,7 +305,10 @@ class Component extends DCLogic {
const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } }) const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } })
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) }) const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
const D = (a) => 'rowDisplay' const D = (a) => 'rowDisplay'
const sessGroups = [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']]), sg('Sites and authors writes', [['POST', '/v1/site/', 'Create a site'], ['PATCH', '/v1/site/{id}', 'Update a site'], ['PATCH', '/v1/site/{id}/default', 'Set the default site'], ['DELETE', '/v1/site/{id}', 'Delete a site (owner)'], ['POST', '/v1/author/me', 'Create my author profile'], ['POST', '/v1/author/', 'Create an author'], ['PATCH', '/v1/author/{id}', 'Update an author'], ['DELETE', '/v1/author/{id}', 'Delete an author']]), sg('Image variants', [['GET', '/v1/image-variants/', 'List variants'], ['POST', '/v1/image-variants/', 'Create a variant'], ['PUT', '/v1/image-variants/{name}', 'Replace a variant'], ['PATCH', '/v1/image-variants/{name}', 'Update a variant'], ['DELETE', '/v1/image-variants/{name}', 'Delete a variant'], ['POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'], ['GET', '/v1/image-variants/cache', 'Image cache status'], ['POST', '/v1/image-variants/cache/purge', 'Purge the image cache'], ['GET', '/v1/media/{id}/variants', 'List the variants of an image'], ['POST', '/v1/media/{id}/variants', 'Add variants to an image'], ['POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'], ['DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image']]), sg('Activity', [['GET', '/v1/activity/', 'List activity'], ['GET', '/v1/activity/summary', 'Activity summary'], ['GET', '/v1/activity/export', 'Export activity']])]
const sessN = sessGroups.reduce((n, g) => n + g.rows.length, 0)
return { return {
sessGroups, sessCount: sessN + ' routes, not available to API keys',
rootRef: this.rootRef, onScroll: this.onScroll, trigRing: ((this.props.s && this.props.s.int) || {}).trig === 'focus' ? '2px solid var(--ui-primary)' : 'none', imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })), rootRef: this.rootRef, onScroll: this.onScroll, trigRing: ((this.props.s && this.props.s.int) || {}).trig === 'focus' ? '2px solid var(--ui-primary)' : 'none', imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index, activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px', pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
@@ -269,17 +318,18 @@ class Component extends DCLogic {
groups: this.eps(), groups: this.eps(),
rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap', rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap',
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto', fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center', sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3', errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errNoteCol: mob ? '2' : '3',
errRows: [ errRows: [
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' }, { c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' },
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' }, { c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
{ c: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' }, { c: 403, m: 'Forbidden', n: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this.", "Only owners and admins can do this." or "You must verify your account to access this resource."' },
{ c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' }, { c: 404, m: 'Not found', n: 'Text in code style is the literal body of the response, not a sentence.', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Content types and entries answer `content_type_not_found` or `entry_not_found`. Drafts answer 404 to anonymous callers.' },
{ c: 409, m: 'Conflict', w: 'Content types: `content_type_key_taken`, `content_type_has_entries`, `content_type_referenced` or `schema_incompatible`. Entries: \`invalid_entry: <reason>\`, only when a unique field clashes. Media and users: "Some of the images to merge changed. Refresh and try again." or "This address already has an account." Articles: `slug_taken`.' },
{ c: 422, m: 'Unprocessable', w: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.' },
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' }, { c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
{ c: 500, m: 'Server error', w: '"Internal server error"' } { c: 500, m: 'Server error', w: '"Internal server error"' }
], ].map(r => ({ ...r, parts: r.w.split('`').map((x, i) => ({ t: x, c: i % 2 === 1, p: i % 2 === 0 })).filter(x => x.t) })),
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.' errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
} }
} }
+38 -13
View File
@@ -57,7 +57,8 @@ class Component extends DCLogic {
const L = this.L, V = this.V, MM = this.MM, SS = 'This action needs a real session.' const L = this.L, V = this.V, MM = this.MM, SS = 'This action needs a real session.'
const E = (id, m, p, d, acc, f, e, x = {}) => ({ id, m, p, d, acc, f, e: acc === 'Verified user' ? e.flatMap(r => r[0] === 401 ? [r, [403, V]] : [r]) : e, ...x }) const E = (id, m, p, d, acc, f, e, x = {}) => ({ id, m, p, d, acc, f, e: acc === 'Verified user' ? e.flatMap(r => r[0] === 401 ? [r, [403, V]] : [r]) : e, ...x })
const S = (id, m, p, d) => ({ id, m, p, d, acc: 'Session only', f: [], e: [[403, SS]], sess: 1 }) const S = (id, m, p, d) => ({ id, m, p, d, acc: 'Session only', f: [], e: [[403, SS]], sess: 1 })
const a404 = [404, 'Article not found'], i404 = [404, 'Image not found'] const a404 = [404, 'Article not found'], i404 = [404, 'Image not found'], eAd = [403, 'Only owners and admins can do this.'], ctnf = [404, 'content_type_not_found'], enf = [404, 'entry_not_found']
const uq = [409, 'invalid_entry: title must be unique'], ie = [422, 'invalid_entry: capacity must be at most 5000'], evf = ['data', 'title', 'starts_on', 'capacity', 'online', 'category', 'cover']
const eps = [ const eps = [
E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }), E('art-list', 'GET', '/v1/article/', 'List articles, one page at a time.', 'Public', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }),
E('art-get', 'GET', '/v1/article/{id}', 'Get one article.', 'Public', [], [[401, L], a404]), E('art-get', 'GET', '/v1/article/{id}', 'Get one article.', 'Public', [], [[401, L], a404]),
@@ -68,12 +69,30 @@ class Component extends DCLogic {
E('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', [], [[401, L], a404]), E('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', [], [[401, L], a404]),
E('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article.', 'Public', [], [[401, L], a404]), E('seo-get', 'GET', '/v1/article/{id}/seo', 'The resolved SEO object for one article.', 'Public', [], [[401, L], a404]),
E('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site.', 'Public', ['page', 'size'], [[400, MM], [401, L], [404, 'site_not_found']], { qp: 1 }), E('sitemap', 'GET', '/v1/site/{key}/sitemap', 'Published article URLs for one site.', 'Public', ['page', 'size'], [[400, MM], [401, L], [404, 'site_not_found']], { qp: 1 }),
E('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', [], [[401, L]]), E('ct-list', 'GET', '/v1/content-type/', 'List all custom content types.', 'Verified user', [], [[401, L]]),
E('ct-get', 'GET', '/v1/content-type/{key}', 'Get one content type with its fields.', 'Verified user', [], [[401, L], ctnf]),
E('ct-create', 'POST', '/v1/content-type/', 'Create a content type.', 'Owner or admin', ['key', 'name', 'icon', 'fields'], [[400, MM], [401, L], eAd, [409, 'content_type_key_taken']]),
E('ct-update', 'PUT', '/v1/content-type/{key}', 'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change.', 'Owner or admin', ['name', 'icon', 'fields'], [[400, MM], [401, L], eAd, ctnf, [409, 'schema_incompatible Some existing entries don\'t fit the new fields. Nothing was changed.']]),
E('ct-delete', 'DELETE', '/v1/content-type/{key}', 'Delete a content type. Only possible when it has no entries and no relation field points at it.', 'Owner or admin', [], [[401, L], eAd, ctnf, [409, 'content_type_has_entries'], [409, 'content_type_referenced']]),
E('en-list', 'GET', '/v1/content-type/{key}/entry', 'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.', 'Verified user', ['page', 'size', 'sort'], [[400, 'Invalid page, size or sort.'], [401, L], ctnf], { qp: 1 }),
E('en-create', 'POST', '/v1/content-type/{key}/entry', 'Create an entry.', 'Verified user', evf, [[400, MM], [401, L], ctnf, uq, ie]),
E('en-get', 'GET', '/v1/content-type/{key}/entry/{id}', 'Get one entry.', 'Verified user', [], [[400, 'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'], [401, L], enf, ctnf]),
E('en-update', 'PUT', '/v1/content-type/{key}/entry/{id}', 'Replace the data of an entry.', 'Verified user', evf, [[400, MM], [401, L], enf, ctnf, uq, ie]),
E('en-delete', 'DELETE', '/v1/content-type/{key}/entry/{id}', 'Delete an entry.', 'Verified user', [], [[401, L], enf, ctnf]),
E('bt-list', 'GET', '/v1/builtin-type/', 'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.', 'Owner or admin', [], [[401, L], eAd]),
E('bt-get', 'GET', '/v1/builtin-type/{key}', 'Get one built-in type. Keys are article, author, site, media and user.', 'Owner or admin', [], [[401, L], eAd, [404, 'builtin_type_not_found']]),
E('med-list', 'GET', '/v1/media/', 'List images, one page at a time. The total is in the X-Total-Count response header; the body stays a JSON array.', 'Verified user', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }),
E('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', ['file'], [[400, MM], [401, L], [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']]), E('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', ['file'], [[400, MM], [401, L], [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']]),
E('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', ['filename'], [[400, MM], [401, L], i404]), E('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', ['filename'], [[400, MM], [401, L], i404]),
E('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image.', 'Verified user', [], [[401, L], i404]), E('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image.', 'Verified user', [], [[401, L], i404]),
E('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', ['keep_id', 'merge_ids'], [[400, MM], [401, L], [409, 'Some of the images to merge changed. Refresh and try again.']]), E('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', ['keep_id', 'merge_ids'], [[400, MM], [401, L], [409, 'Some of the images to merge changed. Refresh and try again.']]),
E('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', [], [[401, L], i404]), E('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', [], [[401, L], i404]),
E('med-purge-settings-get', 'GET', '/v1/media/purge-settings', 'Get the settings for purging unused images.', 'Owner or admin', [], [[401, L], eAd]),
E('med-purge-settings-patch', 'PATCH', '/v1/media/purge-settings', 'Update the settings for purging unused images.', 'Owner or admin', ['auto_purge_enabled', 'auto_purge_interval_days'], [[400, MM], [401, L], eAd]),
E('med-purge-preview', 'GET', '/v1/media/purge-preview', 'Preview the images a purge would delete.', 'Owner or admin', [], [[401, L], eAd]),
E('med-purge', 'POST', '/v1/media/purge', 'Delete the images a purge would remove. failed_groups counts groups that could not be purged; the others were still purged.', 'Owner or admin', ['fingerprint'], [[401, L], eAd]),
E('site-list', 'GET', '/v1/site/', 'List sites', 'Verified user', [], [[401, L]]),
E('author-list', 'GET', '/v1/author/', 'List authors, one page at a time', 'Verified user', ['page', 'size'], [[400, 'Invalid page or size.'], [401, L]], { qp: 1 }),
E('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', [], [[401, L]]), E('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', [], [[401, L]]),
E('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', ['display_name'], [[400, MM], [401, L]]), E('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', ['display_name'], [[400, MM], [401, L]]),
E('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', ['email'], [[400, MM], [401, L], [409, 'This address already has an account.']]), E('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', ['email'], [[400, MM], [401, L], [409, 'This address already has an account.']]),
@@ -83,23 +102,29 @@ class Component extends DCLogic {
E('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', [], [[401, L]]), E('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', [], [[401, L]]),
S('s-login', 'POST', '/v1/auth/login', 'Log in'), S('s-logout', 'POST', '/v1/auth/logout', 'Log out'), S('s-reg', 'POST', '/v1/auth/register', 'Register'), S('s-verify', 'POST', '/v1/auth/verify', 'Verify email'), S('s-reset', 'POST', '/v1/auth/password-reset', 'Reset password'), S('s-login', 'POST', '/v1/auth/login', 'Log in'), S('s-logout', 'POST', '/v1/auth/logout', 'Log out'), S('s-reg', 'POST', '/v1/auth/register', 'Register'), S('s-verify', 'POST', '/v1/auth/verify', 'Verify email'), S('s-reset', 'POST', '/v1/auth/password-reset', 'Reset password'),
S('s-keys', 'GET', '/v1/api-key', 'List keys'), S('s-kcreate', 'POST', '/v1/api-key', 'Create a key'), S('s-krev', 'DELETE', '/v1/api-key/{id}', 'Revoke a key'), S('s-keys', 'GET', '/v1/api-key', 'List keys'), S('s-kcreate', 'POST', '/v1/api-key', 'Create a key'), S('s-krev', 'DELETE', '/v1/api-key/{id}', 'Revoke a key'),
S('s-site-c', 'POST', '/v1/site/', 'Create a site'), S('s-site-u', 'PATCH', '/v1/site/{id}', 'Update a site'), S('s-site-d', 'PATCH', '/v1/site/{id}/default', 'Set the default site'), S('s-site-x', 'DELETE', '/v1/site/{id}', 'Delete a site (owner)'),
S('s-au-me', 'POST', '/v1/author/me', 'Create my author profile'), S('s-au-c', 'POST', '/v1/author/', 'Create an author'), S('s-au-u', 'PATCH', '/v1/author/{id}', 'Update an author'), S('s-au-x', 'DELETE', '/v1/author/{id}', 'Delete an author'),
S('s-iv-l', 'GET', '/v1/image-variants/', 'List variants'), S('s-iv-c', 'POST', '/v1/image-variants/', 'Create a variant'), S('s-iv-p', 'PUT', '/v1/image-variants/{name}', 'Replace a variant'), S('s-iv-pa', 'PATCH', '/v1/image-variants/{name}', 'Update a variant'), S('s-iv-d', 'DELETE', '/v1/image-variants/{name}', 'Delete a variant'), S('s-iv-pu', 'POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'), S('s-iv-ca', 'GET', '/v1/image-variants/cache', 'Image cache status'), S('s-iv-cp', 'POST', '/v1/image-variants/cache/purge', 'Purge the image cache'),
S('s-mv-l', 'GET', '/v1/media/{id}/variants', 'List the variants of an image'), S('s-mv-c', 'POST', '/v1/media/{id}/variants', 'Add variants to an image'), S('s-mv-g', 'POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'), S('s-mv-d', 'DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image'),
S('s-ac-l', 'GET', '/v1/activity/', 'List activity'), S('s-ac-s', 'GET', '/v1/activity/summary', 'Activity summary'), S('s-ac-e', 'GET', '/v1/activity/export', 'Export activity'),
S('s-upd', 'GET', '/v1/system/update', 'Update status'), S('s-chk', 'POST', '/v1/system/update/check', 'Check for update'), S('s-dl', 'POST', '/v1/system/update/download', 'Download update'), S('s-rst', 'POST', '/v1/system/update/restart', 'Restart') S('s-upd', 'GET', '/v1/system/update', 'Update status'), S('s-chk', 'POST', '/v1/system/update/check', 'Check for update'), S('s-dl', 'POST', '/v1/system/update/download', 'Download update'), S('s-rst', 'POST', '/v1/system/update/restart', 'Restart')
] ]
const secs = [ const secs = [
['auth', 'Authentication', 'Public access and API keys', 'bearer api key rcms rate limit 429 base url public hash'], ['auth', 'Authentication', 'Public access and API keys', 'bearer api key rcms rate limit 429 base url public hash'],
['articles', 'Articles', '7 routes', ''], ['seo', 'Search and sharing', '2 routes', 'seo sitemap'], ['media', 'Media', '6 routes', ''], ['articles', 'Articles', '7 routes', ''], ['seo', 'Search and sharing', '2 routes', 'seo sitemap'], ['ctypes', 'Content types', '5 routes', 'custom fields field types schema'], ['entries', 'Entries', '5 routes', 'content type custom data'], ['builtin', 'Built-in types', '2 routes', 'builtin fields schema'], ['media', 'Media', '10 routes', 'purge'], ['sites', 'Sites and authors', '2 routes', 'site author'],
['images', 'Images and variants', 'The ?v= parameter', 'variant image width height format quality'], ['images', 'Images and variants', 'The ?v= parameter', 'variant image width height format quality'],
['users', 'Users and invites', '6 routes', ''], ['settings', 'Settings', '1 route', ''], ['users', 'Users and invites', '6 routes', ''], ['settings', 'Settings', '1 route', ''],
['session', 'Session only', '12 routes, not available to API keys', 'login log in register password real session'], ['session', 'Session only', '35 routes, not available to API keys', 'login log in register password real session'],
['errors', 'Errors', 'Status codes and messages', '400 401 403 404 429 500 status error plain text'], ['errors', 'Errors', 'Status codes and messages', '400 401 403 404 409 422 429 500 status error plain text'],
['notes', 'Notes', 'Images, CORS and ?format=', 'cors origins format markdown html json body_format'] ['notes', 'Notes', 'Images, CORS and ?format=', 'cors origins format markdown html json body_format']
] ]
return { eps, secs } return { eps, secs }
} }
has(s, t) { return s.includes(t) || (t === 'entry' && s.includes('entries')) }
hl(t, toks) { hl(t, toks) {
if (!toks.length) return [{ t, bg: 'transparent', fg: 'inherit' }] if (!toks.length) return [{ t, bg: 'transparent', fg: 'inherit' }]
const re = new RegExp('(' + toks.map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'ig') const re = new RegExp('(' + toks.map(x => x === 'entry' ? 'entr(?:y|ies)' : x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'ig')
return t.split(re).filter(x => x !== '').map(x => { const on = toks.some(k => k === x.toLowerCase()); return { t: x, bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'inherit' } }) return t.split(re).filter(x => x !== '').map(x => { const on = toks.some(k => k === x.toLowerCase() || (k === 'entry' && x.toLowerCase() === 'entries')); return { t: x, bg: on ? 'var(--rc-sand)' : 'transparent', fg: on ? 'var(--rc-on-sand)' : 'inherit' } })
} }
compute() { compute() {
const { eps, secs } = this.data(), q = this.state.q.trim().toLowerCase(), toks = q ? q.split(/\s+/) : [] const { eps, secs } = this.data(), q = this.state.q.trim().toLowerCase(), toks = q ? q.split(/\s+/) : []
@@ -111,24 +136,24 @@ class Component extends DCLogic {
const hayP = (e.m + ' ' + e.p + ' ' + e.d + ' ' + e.acc).toLowerCase() const hayP = (e.m + ' ' + e.p + ' ' + e.d + ' ' + e.acc).toLowerCase()
let via = null, ok = true let via = null, ok = true
toks.forEach(t => { toks.forEach(t => {
if (hayP.includes(t)) return if (this.has(hayP, t)) return
const f = e.f.find(x => x.toLowerCase().includes(t)) const f = e.f.find(x => this.has(x.toLowerCase(), t))
if (f) { via = via || { label: e.qp ? 'Query parameter' : 'Body field', text: f }; return } if (f) { via = via || { label: e.qp ? 'Query parameter' : 'Body field', text: f }; return }
const r = e.e.find(([c, m]) => (c + ' ' + m).toLowerCase().includes(t)) const r = e.e.find(([c, m]) => this.has((c + ' ' + m).toLowerCase(), t))
if (r) { via = via || { label: 'Error', text: r[0] + ' ' + r[1] }; return } if (r) { via = via || { label: 'Error', text: r[0] + ' ' + r[1] }; return }
ok = false ok = false
}) })
if (ok) ep.push({ e, via }) if (ok) ep.push({ e, via })
}) })
ep.sort((a, b) => (a.via ? 1 : 0) - (b.via ? 1 : 0)) ep.sort((a, b) => (a.via ? 1 : 0) - (b.via ? 1 : 0))
sc = secs.filter(s => toks.every(t => (s[1] + ' ' + s[2] + ' ' + s[3]).toLowerCase().includes(t))) sc = secs.filter(s => toks.every(t => this.has((s[1] + ' ' + s[2] + ' ' + s[3]).toLowerCase(), t)))
} }
return { ep, sc, toks, title } return { ep, sc, toks, title }
} }
renderVals() { renderVals() {
const mob = !!this.props.mobile, st = this.state, { ep, sc, toks, title } = this.compute() const mob = !!this.props.mobile, st = this.state, { ep, sc, toks, title } = this.compute()
const CAP = 6, shown = ep.slice(0, CAP), total = ep.length + sc.length const CAP = 6, shown = ep.slice(0, CAP), total = ep.length + sc.length
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] } const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], PUT: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
const sel = total ? Math.min(st.sel, shown.length + sc.length - 1) : 0 const sel = total ? Math.min(st.sel, shown.length + sc.length - 1) : 0
let idx = 0 let idx = 0
const mkItem = (o) => o const mkItem = (o) => o
@@ -151,7 +176,7 @@ class Component extends DCLogic {
const flat = groups.flatMap(g => g.items) const flat = groups.flatMap(g => g.items)
flat.forEach((it, i) => { const on = i === sel; Object.assign(it, { id: 'rc-sr-' + i, sel: on, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', bar: on ? 'inset 2px 0 0 var(--ui-primary)' : 'none', go: () => this.setState({ sel: i }) }) }) flat.forEach((it, i) => { const on = i === sel; Object.assign(it, { id: 'rc-sr-' + i, sel: on, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', bar: on ? 'inset 2px 0 0 var(--ui-primary)' : 'none', go: () => this.setState({ sel: i }) }) })
return { return {
groups, none, noneText: 'No match for "' + this.state.q.trim() + '". Try a path, a method or a status code like 404.', more, moreText: (ep.length - CAP) + ' more endpoints. Keep typing to narrow the list.', groups, none, noneText: 'No match for "' + this.state.q.trim() + '". Try a path, a method or a status code like 404.', more, moreText: (ep.length - CAP) + ' more endpoint' + (ep.length - CAP === 1 ? '' : 's') + '. Keep typing to narrow the list.',
live, count: none ? '0 results' : !toks.length ? '5 suggestions' : plural(total, 'result'), activeId, q: st.q, live, count: none ? '0 results' : !toks.length ? '5 suggestions' : plural(total, 'result'), activeId, q: st.q,
desk: !mob, mob, w: mob ? '100%' : '720px', h: mob ? '100%' : 'auto', bd: mob ? '0' : '1px solid var(--ui-border)', shellPad: mob ? '0' : '96px 24px 24px', desk: !mob, mob, w: mob ? '100%' : '720px', h: mob ? '100%' : 'auto', bd: mob ? '0' : '1px solid var(--ui-border)', shellPad: mob ? '0' : '96px 24px 24px',
inH: mob ? '56px' : '52px', inPx: mob ? '16px' : '16px', inFs: mob ? '16px' : '15px', ftH: mob ? '40px' : '40px', listMax: mob ? 'none' : '440px', rowPx: mob ? '16px' : '16px', inH: mob ? '56px' : '52px', inPx: mob ? '16px' : '16px', inFs: mob ? '16px' : '15px', ftH: mob ? '40px' : '40px', listMax: mob ? 'none' : '440px', rowPx: mob ? '16px' : '16px',
@@ -368,12 +368,22 @@ class Component extends DCLogic {
...((B, X) => [ ...((B, X) => [
X({ name: "8a. Search: trigger focused", trigger: "Closed. Tab or the \"/\" key puts focus on the search field-style button in the page header (2 px --ui-primary ring, 2 px offset). On mobile the icon button sits right of \"Jump to\".", int: { trig: 'focus' } }, { int: {} }), X({ name: "8a. Search: trigger focused", trigger: "Closed. Tab or the \"/\" key puts focus on the search field-style button in the page header (2 px --ui-primary ring, 2 px offset). On mobile the icon button sits right of \"Jump to\".", int: { trig: 'focus' } }, { int: {} }),
X({ name: "8b. Search: open, empty", trigger: "Ctrl or Cmd+K, \"/\" or a click. Focus is in the input. Five suggested items, no query. Footer count: \"5 suggestions\".", search: { q: '', sel: 0 } }), X({ name: "8b. Search: open, empty", trigger: "Ctrl or Cmd+K, \"/\" or a click. Focus is in the input. Five suggested items, no query. Footer count: \"5 suggestions\".", search: { q: '', sel: 0 } }),
X({ name: "8c. Search: typing, results", trigger: "\"media\". Endpoints first, then Sections. Matched characters use --rc-sand with --rc-on-sand. First result is selected. Announced: \"7 results\".", search: { q: 'media', sel: 0 } }), X({ name: "8c. Search: typing, results", trigger: "\"media\". Endpoints first, then Sections. Matched characters use --rc-sand with --rc-on-sand. First result is selected. Announced: \"17 results, showing 7\".", search: { q: 'media', sel: 0 } }),
X({ name: "8d. Search: keyboard-selected, status code", trigger: "\"404\" matches error codes. Down arrow moved the selection to the third row (--ui-bg-elevated plus a 2 px --ui-primary bar). Rows matched outside the path show the matching error under the description. Only 6 endpoints are listed; the rest are counted. Announced: \"13 results, showing 7\".", search: { q: '404', sel: 2 } }), X({ name: "8d. Search: keyboard-selected, status code", trigger: "\"404\" matches error codes. Down arrow moved the selection to the third row (--ui-bg-elevated plus a 2 px --ui-primary bar). Rows matched outside the path show the matching error under the description. Only 6 endpoints are listed; the rest are counted. Announced: \"22 results, showing 7\".", search: { q: '404', sel: 2 } }),
X({ name: "8e. Search: body field match", trigger: "\"keep_id\" is a request body field of POST /v1/media/merge. The row adds a \"Body field\" line with the match highlighted. Announced: \"1 result\".", search: { q: 'keep_id', sel: 0 } }), X({ name: "8e. Search: body field match", trigger: "\"keep_id\" is a request body field of POST /v1/media/merge. The row adds a \"Body field\" line with the match highlighted. Announced: \"1 result\".", search: { q: 'keep_id', sel: 0 } }),
X({ name: "8f. Search: session-only results", trigger: "\"login\". Session-only routes keep the card look: muted text, dashed method badge, dashed \"Session only\" chip with the lock icon. Announced: \"2 results\".", search: { q: 'login', sel: 0 } }), X({ name: "8f. Search: session-only results", trigger: "\"login\". Session-only routes keep the card look: muted text, dashed method badge, dashed \"Session only\" chip with the lock icon. Announced: \"2 results\".", search: { q: 'login', sel: 0 } }),
X({ name: "8g. Search: no results", trigger: "\"xyz\". No option is selectable. Announced: \"No results\". Footer count: \"0 results\".", search: { q: 'xyz', sel: 0 } }), X({ name: "8g. Search: no results", trigger: "\"xyz\". No option is selectable. Announced: \"No results\". Footer count: \"0 results\".", search: { q: 'xyz', sel: 0 } }),
X({ name: "8h. Search: after selecting an endpoint", trigger: "Enter on POST /v1/media/merge. The palette closes, the page scrolls to the card, the card expands and takes focus, and the row shows a highlight (--rc-sand fill, 2 px --ui-primary ring) for 1.5 s, then fades to the open-card fill. The section index (desktop) and \"Jump to\" (mobile) now read Media.", int: { scroll: 'ep-med-merge', open: 'med-merge', tab: 'fetch', flash: 'med-merge' } }) X({ name: "8h. Search: after selecting an endpoint", trigger: "Enter on POST /v1/media/merge. The palette closes, the page scrolls to the card, the card expands and takes focus, and the row shows a highlight (--rc-sand fill, 2 px --ui-primary ring) for 1.5 s, then fades to the open-card fill. The section index (desktop) and \"Jump to\" (mobile) now read Media.", int: { scroll: 'ep-med-merge', open: 'med-merge', tab: 'fetch', flash: 'med-merge' } }),
X({ name: "9a. Content types: create, curl", trigger: "Content types group. The Field types table and the sample field object sit above the routes. POST /v1/content-type/ is open on the curl tab with its long body table (key, name, icon, fields). On mobile the view starts at the card.", int: { scroll: 'ct-fields', open: 'ct-create', tab: 'curl' } }, { int: { scroll: 'ep-ct-create', open: 'ct-create', tab: 'curl' } }),
X({ name: "9b. Entries: list, Response tab", trigger: "GET /v1/content-type/{key}/entry open on the Response tab. The response is an object with items, total, page and size. The Query parameters table (page, size, sort) is above the example.", int: { scroll: 'ep-en-list', open: 'en-list', tab: 'response' } }),
X({ name: "9c. Entries: replace, 422 and 409", trigger: "PUT /v1/content-type/{key}/entry/{id} open. Errors list the 409 as a sentence and the 422 as a code-style caption, \"invalid_entry: capacity must be at most 5000\".", int: { scroll: 'ep-en-update', open: 'en-update', tab: 'fetch' } }),
X({ name: "9d. Errors: 404, 409 and 422 rows", trigger: "The table now has real 409 and 422 rows and the two new 404 codes. Codes are the literal body, so they use code style. The 403 row keeps its muted note.", int: { scroll: 'errors' } }),
X({ name: "9e. Session only: 6 sub-groups", trigger: "35 routes in six sub-groups: System, Sign in and account, API key management, Sites and authors writes, Image variants and Activity.", int: { scroll: 'session' } }),
X({ name: "9f. Search: \"entry\"", trigger: "Endpoints are capped at 6 and the rest are counted (\"N more endpoints\"). \"entries\" in a description or error also matches. Announced: \"8 results, showing 7\".", search: { q: 'entry', sel: 0 } }),
X({ name: "9g. Search: \"capacity\" (body field)", trigger: "Matches the capacity key in the data body of POST and PUT entry. The row adds a \"Body field\" line. Announced: \"2 results\".", search: { q: 'capacity', sel: 0 } }),
X({ name: "9h. Search: \"schema_incompatible\" (error)", trigger: "Matches the 409 of PUT /v1/content-type/{key}. The row adds an \"Error\" line. Announced: \"1 result\".", search: { q: 'schema_incompatible', sel: 0 } }),
X({ name: "9i. Search: \"content type\" (sections)", trigger: "Endpoints capped at 6, then a Sections group with Content types and Entries. Announced: \"12 results, showing 8\".", search: { q: 'content type', sel: 0 } }),
X({ name: "9j. Navigation: scroll-spy on Content types", trigger: "Desktop: the sticky index lists every group and marks Content types. Mobile: the Jump to list is open with all groups and a check on Content types.", int: { scroll: 'ctypes' } }, { int: { scroll: 'ctypes', jump: true } })
])({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration' }, (st, mo) => withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', int: {}, ...st }, mo || {})) ])({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration' }, (st, mo) => withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', int: {}, ...st }, mo || {}))
] }, ] },
{ label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [ { label: 'Activity', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', note: 'New route in the sidebar after Users. Owner and admin see everything; regular users get "My activity". Copy, component mapping and API calls are at the bottom of this board.', states: [
+10 -9
View File
@@ -11,13 +11,13 @@ Feature: Integration screen (API reference)
And the PageHeader shows breadcrumb "Integration", title "API reference" and description And the PageHeader shows breadcrumb "Integration", title "API reference" and description
"Everything a developer needs to build on RenewCMS." "Everything a developer needs to build on RenewCMS."
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
And the catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order: And the catalogue holds 42 key-callable endpoints in 9 expandable groups (the Content types, Entries, Built-in types and Sites and authors groups and the extra Media routes are in features/integration_api_key_routes.feature), in this order:
| group | routes | endpoints | | group | routes | endpoints |
| Articles | 7 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Articles | 8 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}, POST /v1/article/{id}/seo/check |
| Media | 6 | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage | | Media | 11 | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET and PATCH /v1/media/purge-settings, GET /v1/media/purge-preview, POST /v1/media/purge |
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me | | Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
| Settings | 1 | GET /v1/settings | | Settings | 1 | GET /v1/settings |
And the "Session only" group has 13 non-expandable rows in 3 sub-groups: And the "Session only" group has 37 non-expandable rows in 6 sub-groups (the full list is in features/integration_api_key_routes.feature); the first three are:
| sub-group | rows | | sub-group | rows |
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart | | System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
| Sign in and account | POST /v1/auth/login, POST /v1/auth/logout, POST /v1/auth/register, POST /v1/auth/verify, DELETE /v1/auth/register, POST /v1/auth/password-reset | | Sign in and account | POST /v1/auth/login, POST /v1/auth/logout, POST /v1/auth/register, POST /v1/auth/verify, DELETE /v1/auth/register, POST /v1/auth/password-reset |
@@ -36,7 +36,7 @@ Feature: Integration screen (API reference)
Scenario: Default, every card collapsed Scenario: Default, every card collapsed
When the user navigates to /integration When the user navigates to /integration
Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office." Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office."
And a "Base URL" label sits above a CodeSnippet with the single tab "Base URL" showing window.location.origin and a "Copy" button (icon copy) And a "Base URL" label sits above a CodeSnippet with the single tab "Base URL" showing window.location.origin and a "Copy" button (icon copy)
And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron
And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
@@ -117,7 +117,7 @@ Feature: Integration screen (API reference)
And its CodeSnippet has tabs curl and fetch for GET /v1/auth/me, using the current origin And its CodeSnippet has tabs curl and fetch for GET /v1/auth/me, using the current origin
And it says "Keys start with `rcms_` and are shown in full once, when you create them." And it says "Keys start with `rcms_` and are shown in full once, when you create them."
And a "Manage API keys" link navigates to /integration/api-keys And a "Manage API keys" link navigates to /integration/api-keys
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified." And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither."
And Note 2 reads "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header." And Note 2 reads "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
Scenario: Session only group Scenario: Session only group
@@ -134,9 +134,10 @@ Feature: Integration screen (API reference)
| status | message | | status | message |
| 400 | The request cannot be processed due to a mismatch in the format of the body. / The expiry must be a date in the future. | | 400 | The request cannot be processed due to a mismatch in the format of the body. / The expiry must be a date in the future. |
| 401 | You must be logged in to access this resource. | | 401 | You must be logged in to access this resource. |
| 403 | This action needs a real session. / Only the owner can do this. / You must verify your account to access this resource. | | 403 | This action needs a real session. / Only owners and admins can do this. / Only the owner can do this. / You must verify your account to access this resource. |
| 404 | Article not found / Image not found / Invite not found / API key not found | | 404 | Article not found / Image not found / Invite not found / API key not found / content_type_not_found / entry_not_found / builtin_type_not_found |
| 409 | There's already a pending invite for this address. / This invite has already been accepted. | | 409 | There's already a pending invite for this address. / This invite has already been accepted. / Some of the images to merge changed. Refresh and try again. / This address already has an account. / content_type_key_taken / content_type_has_entries / content_type_referenced / schema_incompatible / invalid_entry: <reason> / slug_taken |
| 422 | invalid_entry: <reason> |
| 429 | Too many requests. Please try again later. | | 429 | Too many requests. Please try again later. |
| 500 | Internal server error | | 500 | Internal server error |
And a CodeSnippet with the single tab "Error response · text/plain" shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session." And a CodeSnippet with the single tab "Error response · text/plain" shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
+1 -1
View File
@@ -54,7 +54,7 @@ Component specs, one per design state:
6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that 6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that
answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with
the "Public" chip. the "Public" chip.
7. **Errors**: table rows 400, 401, 403, 404, 409, 429, 500 with the real strings and the 403 7. **Errors**: table rows 400, 401, 403, 404, 409, 422, 429, 500 with the real strings and the 403
sample block. sample block.
8. **Navigation**: clicking an index item sets aria-current and scrolls to its anchor; the mobile 8. **Navigation**: clicking an index item sets aria-current and scrolls to its anchor; the mobile
"Jump to" lists the same sections. "Jump to" lists the same sections.
+169
View File
@@ -0,0 +1,169 @@
Feature: Integration page documents every route an API key can call
As a developer building on RenewCMS
I want the API reference to cover content types, entries, built-in types, sites, authors and media purge
So that I can use every route my API key can call without reading the source
Background:
Given the SPA route /integration of the verified back-office (static page, no /v1 call, web/ only)
And the page follows docs/design/screens/integration.md, sections "Custom content types round" and "Final round"
And the boards are IntegrationPage.dc.html, IntegrationSearch.dc.html and states 8c, 8d, 8f and 9a to 9j of "RenewCMS Screens Back-office.dc.html"
And endpoint rows, groups, index and session-only sub-groups come from the catalogue the board builds with A(...), G, IDX and sessGroups
And every sample and count follows the real API, not the board's illustrative values
And the components are Nuxt UI v4 with design tokens only
And every state is drawn in light and dark at 1440x900 and 390x844
And no state is date dependent
Rule: Section index and Jump to
Scenario: Index order
Given the viewport is 1440x900
Then the sticky index lists, in this order:
| Authentication | Articles | Search and sharing | Content types | Entries | Built-in types | Media |
| Sites and authors | Images and variants | Users and invites | Settings | Session only | Errors | Notes |
And on 390x844 the "Jump to" list shows the same items in the same order
Scenario: Scroll-spy on Content types (state 9j)
Given the Content types group is the section nearest the top of the page
Then its index item has aria-current "true" and the 2 px --ui-primary bar
And on mobile the "Jump to" select reads "Content types" and its open list marks that item
Rule: Endpoint groups
Scenario Outline: Group contents
Then the group "<group>" shows "<count>" with these routes and access chips
Examples:
| group | count | routes |
| Articles | 8 routes | unchanged (the 7 article routes and POST /v1/article/{id}/seo/check) |
| Search and sharing | 2 routes | unchanged |
| Content types | 5 routes | GET /v1/content-type/ and GET /v1/content-type/{key} Verified user; POST /v1/content-type/, PUT and DELETE /v1/content-type/{key} Owner or admin |
| Entries | 5 routes | GET and POST /v1/content-type/{key}/entry; GET, PUT and DELETE /v1/content-type/{key}/entry/{id}, all Verified user |
| Built-in types | 2 routes | GET /v1/builtin-type/ and GET /v1/builtin-type/{key}, Owner or admin |
| Media | 11 routes | the 7 existing (including GET /v1/media/{id}); GET and PATCH /v1/media/purge-settings, GET /v1/media/purge-preview, POST /v1/media/purge, Owner or admin |
| Sites and authors | 2 routes | GET /v1/site/ and GET /v1/author/, Verified user (the former Sites and Authors groups, merged) |
| Users and invites | 6 routes | unchanged |
| Settings | 1 route | unchanged |
And every route of these groups carries the chip "API key OK"
And the new groups appear in the order of the index
Scenario: Group descriptions
Then "Content types" reads "Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes."
And "Entries" reads "Read and write the content of a custom type. Any verified user can write entries."
And "Built-in types" says they are read-only through the API and that the keys are article, author, site, media and user
Scenario: Field types reference above the Content types routes
Then an anchor "ct-fields" sits above the first Content types route and is not an index item
And a "Field types" table with columns Type, Value in an entry and Rules lists text, richtext, number, boolean, date, select, media and relation
And a sample field object shows key capacity, label Capacity, type number, required true, unique false, default null, min 1, max 5000, options null, relation null
Scenario: PUT badge
Then the PUT method badge uses the PATCH colours
Scenario Outline: Expanded cards show their request fields and errors (states 9a and 9c)
When I expand "<route>"
Then the request table or query table shows "<fields>"
And the Errors list shows "<errors>"
Examples:
| route | fields | errors |
| POST /v1/content-type/ | key, name, icon, fields | 400, 401, 403 "Only owners and admins can do this.", 409 content_type_key_taken |
| PUT /v1/content-type/{key} | name, icon, fields | 400, 401, 403, 404 content_type_not_found, 409 schema_incompatible |
| DELETE /v1/content-type/{key} | No request body. | 401, 403, 404 content_type_not_found, 409 content_type_has_entries, 409 content_type_referenced |
| GET /v1/content-type/{key}/entry | page, size (5, 25, 50 or 100), sort (id, created_at or updated_at, then :asc or :desc) | 400 "Invalid page, size or sort.", 401, 404 content_type_not_found |
| POST /v1/content-type/{key}/entry | data | 400, 401, 404 content_type_not_found, 409 invalid_entry: title must be unique, 422 invalid_entry: capacity must be at most 5000 |
| GET /v1/content-type/{key}/entry/{id} | No request body. | 400 "The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).", 401, 404 entry_not_found, 404 content_type_not_found |
| PUT /v1/content-type/{key}/entry/{id} | data | 400, 401, 404 entry_not_found, 404 content_type_not_found, 409 and 422 invalid_entry: <reason> |
| GET /v1/builtin-type/{key} | No request body. | 401, 403, 404 builtin_type_not_found |
And code-style captions show the literal body
Scenario: Response samples follow the real API
Then POST cards show only the JSON body, with no status line
And the entries list Response is an object with items, total, page and size, not a bare array
And an entry sample has id, content_type, data, created_by, updated_by, created_at, updated_at, media and relations
And the entry media sample is keyed by the field key, for example cover
And a built-in type sample has only key, name and fields, with no icon
And 409 on an entry only happens when a unique field clashes; any other field error is 422
Scenario: GET /v1/media/ is paginated
When I expand "GET /v1/media/"
Then a "Query parameters" table lists page and size (5, 25, 50 or 100, default 25) next to q, hash, filter, sort and variants
And the Response is an object with items, total, page, size and counts
And the Errors list has a 400 row "The media query is invalid."
Scenario: Media purge routes
Then GET and PATCH /v1/media/purge-settings use the keys auto_purge_enabled, auto_purge_interval_days (1 to 365), last_purge_at and last_purge_merged
And POST /v1/media/purge takes an optional fingerprint taken from purge-preview
And its Response sample has exactly groups_total, groups_purged, merged, articles_updated, sites_updated, authors_updated and failed_groups
And its description says failed_groups counts groups that could not be purged and the others were still purged
And no sample uses articlesUpdated or count for the purge response
Scenario: Sites and authors lists
Then GET /v1/site/ lists sites
And GET /v1/author/ has Query parameters page and size and returns an object with items, total, page and size
Rule: Session only
Scenario: Thirty-seven routes in six sub-groups (state 9e)
Then the heading reads "Session only" and the count reads "37 routes, not available to API keys", derived from the data
And the sub-groups are, in order: "System · owner only", "Sign in and account", "API key management", "Sites and authors writes", "Image variants", "Activity"
And every /v1/system route is in "System · owner only", with PATCH /v1/settings
And "Sign in and account" holds DELETE /v1/auth/register
And each row is dashed, muted and cannot expand
And each row that answers 403 to a key has the lock icon and the "Session only" chip; Log in, Log out, Register and Reset password keep their caption "Made for the browser. A key is ignored here, with no 403." and no chip
And the banner is unchanged
Rule: Errors table (state 9d)
Scenario: Rows and copy
Then the table has rows 400, 401, 403, 404, 409, 422, 429 and 500
And the 403 row lists "This action needs a real session.", "Only owners and admins can do this.", "Only the owner can do this." and "You must verify your account to access this resource."
And the 403 row keeps its muted note about site SEO defaults
And the 404 row adds content_type_not_found, entry_not_found and builtin_type_not_found
And the 409 row lists content_type_key_taken, content_type_has_entries, content_type_referenced, schema_incompatible, a unique-value clash and the existing media and user examples
And the 422 row lists "invalid_entry: <reason>"
And a note says code-style text is the literal body
And the old sentence saying there is no 409 or 422 row is gone
Rule: Authentication copy
Scenario: Key rights
Then the API key card says requests act as the user who created the key, with that user's role
And the page says a key can manage articles, media and users, and manage content types and entries, without the back-office
And Note 1 gains the sentence "Owner and admin routes answer 403 to a key whose creator is neither."
And the other authentication notes are unchanged
Rule: Search covers the new routes
Scenario: Index content
Then the search index holds 85 endpoints: 42 documented, 37 session-only and 6 public auth
And it includes the new endpoints, their body fields and query parameters, the events sample keys under data (title, starts_on, capacity, online, category, cover) as "Body field", and the new error codes and messages
Scenario Outline: Queries and counts
When I type "<query>" in the palette
Then the count and live region read "<announced>"
And the notes are "<notes>"
Examples:
| query | announced | notes |
| media | 17 results, showing 7 | Section Media reads "11 routes" |
| 404 | 24 results, showing 7 | rows show an "Error" second line |
| login | 2 results | Session only section reads "37 routes, not available to API keys" |
| entry | 8 results, showing 7 | also matches "entries" |
| capacity | 2 results | second line "Body field capacity" on POST and PUT entry |
| schema_incompatible | 1 result | second line "Error" on PUT /v1/content-type/{key} |
| content type | 12 results, showing 8 | Sections group lists Content types and Entries |
Scenario: Cap line is singular for one
Given exactly one endpoint is beyond the cap
Then the line reads "1 more endpoint. Keep typing to narrow the list."
And with several it reads "N more endpoints. Keep typing to narrow the list."
Scenario: Selecting a new endpoint
When I select PUT /v1/content-type/{key}
Then the palette closes, the card is scrolled to, expanded and focused, and flashes for 1.5 s
And the URL hash is #ep-ct-update
And the index and "Jump to" read "Content types"
Rule: Existing states stay
Scenario: Unchanged behaviour
Then states 1 to 7, 8a to 8h, the Images and variants section and the "Manage API keys" link behave as before
And figures in the existing specs (media routes, result counts, session-only count, the 409 and 422 sentence) are corrected to this feature
+47
View File
@@ -0,0 +1,47 @@
# QA procedure: Integration page, every API-key route documented
No Go test: SPA only, no /v1 call, no API change. Samples and counts follow the real API; where the board differs (purge response, built-in type samples) the API wins.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Files: extend `web/src/views/__tests__/IntegrationView.spec.ts`, `web/src/views/__tests__/IntegrationSearch.spec.ts` and the pure matcher spec if present. Same setup as the existing specs (router at `/integration`, stubbed scroll helpers, fake timers for the flash). No test freezes the system date. Also correct the superseded figures in the existing specs.
Cases (`it.each` for outlines):
1. Index order, 14 items, desktop index and mobile select.
2. Group table: titles, counts (Articles 8, Media 11, Sites and authors 2, "1 route" singular for Settings), method, path and access chip per route, "API key OK" chip.
3. `ct-fields` anchor above the first Content types route, not an index item; Field types table rows; sample field object.
4. PUT badge colours equal the PATCH colours.
5. Expanded card outline: request or query fields and error rows for the eight routes of the outline, with code-style captions.
6. Response samples: entries list shape, entry sample keys, `media` keyed by field key, built-in type has no `icon`, purge response has exactly the seven keys, purge-settings keys, POST cards without a status line.
7. GET /v1/media/: Query parameters (page, size), object response, 400 "The media query is invalid.". Real API: no X-Total-Count header and no "Invalid page or size." on media.
8. GET /v1/author/ page and size; GET /v1/site/.
9. Session only: derived "37 routes", six sub-groups in order, every `/v1/system` route in System (PATCH /v1/settings joins it), DELETE /v1/auth/register under Sign in and account, row styling, unchanged banner.
10. Errors table: eight rows (400, 401, 403, 404, 409, 422, 429, 500), both 403 texts plus the note, new 404 codes, 409 and 422 content, literal-body note, old "no 409 or 422 row" sentence absent.
11. Authentication copy.
12. Search: index size 85 (42 documented, 37 session-only, 6 public auth); each query of the outline with its announced count and second line; "entry" matches "entries"; cap line singular and plural.
13. Select PUT /v1/content-type/{key}: card expanded and focused, flash class then removed after 1500 ms, hash `#ep-ct-update`, index reads "Content types".
14. Scroll-spy marks Content types (state 9j).
15. Regression: the existing cases still pass.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
References: `docs/design/screens/integration.md`, `docs/design/source/IntegrationPage.dc.html`, `IntegrationSearch.dc.html`, states of `RenewCMS Screens Back-office.dc.html`. Add setups to `web/visual/states.ts` with no /v1 call unmocked, using the board's sample data (events type, entry sample, fingerprint c41f9a7e).
| state | setup |
|---|---|
| 8c, 8d, 8f | updated counts |
| 9a | scroll to ct-fields, open ct-create on curl |
| 9b | open en-list on Response |
| 9c | open en-update on fetch |
| 9d | scroll to errors |
| 9e | scroll to session |
| 9f to 9i | search "entry", "capacity", "schema_incompatible", "content type" |
| 9j | scroll-spy on Content types; mobile Jump to open |
No clock-sensitive state exists. A diff exception needs the user's explicit approval.
## Manual checks (screenshots cannot cover)
- The search palette and the Session only list on mobile (design tool did not confirm them), and dark mode.
- Keyboard and focus order across the new groups, hover, the 1.5 s flash and its reduced-motion ring.
- Scroll offset under the sticky bar for `ct-fields` and the last Session only sub-group.
- Long paths such as `/v1/content-type/{key}/entry/{id}` ellipsise in collapsed rows and in the palette on mobile.
+8 -8
View File
@@ -106,20 +106,20 @@ Feature: Integration page search, a jump-to command palette on /integration
Scenario: 8c Typing "media" Scenario: 8c Typing "media"
When the user types "media" When the user types "media"
Then group "Endpoints" lists the first 6 of 11 media routes Then group "Endpoints" lists the first 6 of the 16 matching routes (GET /v1/builtin-type/ first, because its description names Media, then the Media routes)
And group "Sections" lists "Media" with description "11 routes" And group "Sections" lists "Media" with description "11 routes"
And the line "5 more endpoints. Keep typing to narrow the list." follows the rows And the line "10 more endpoints. Keep typing to narrow the list." follows the rows
And the count reads "12 results" And the count reads "17 results"
And the live region reads "12 results, showing 7" And the live region reads "17 results, showing 7"
And "media" is highlighted with --rc-sand on --rc-on-sand in paths and descriptions And "media" is highlighted with --rc-sand on --rc-on-sand in paths and descriptions
Scenario: 8d More endpoints than the cap Scenario: 8d More endpoints than the cap
When the user types "404" When the user types "404"
Then group "Endpoints" lists 6 rows, each with a second line "Error" and the match, for example "404 Article not found" Then group "Endpoints" lists 6 rows, each with a second line "Error" and the match, for example "404 Article not found"
And group "Sections" lists "Errors" with description "Status codes and messages" And group "Sections" lists "Errors" with description "Status codes and messages"
And the line "8 more endpoints. Keep typing to narrow the list." follows the endpoint rows And the line "17 more endpoints. Keep typing to narrow the list." follows the endpoint rows
And the count reads "15 results" And the count reads "24 results"
And the live region reads "15 results, showing 7" And the live region reads "24 results, showing 7"
Scenario: 8e Match on a body field Scenario: 8e Match on a body field
When the user types "keep_id" When the user types "keep_id"
@@ -132,7 +132,7 @@ Feature: Integration page search, a jump-to command palette on /integration
When the user types "login" When the user types "login"
Then group "Endpoints" lists POST /v1/auth/login, "Log in", with a dashed "Session only" chip and a lock icon Then group "Endpoints" lists POST /v1/auth/login, "Log in", with a dashed "Session only" chip and a lock icon
And its path weight is 400 and its text is --ui-text-muted And its path weight is 400 and its text is --ui-text-muted
And group "Sections" lists "Session only" with a lock icon and "25 routes, not available to API keys" And group "Sections" lists "Session only" with a lock icon and "37 routes, not available to API keys"
And the count reads "2 results" And the count reads "2 results"
Scenario: 8g No results Scenario: 8g No results
+5 -5
View File
@@ -17,10 +17,10 @@ Cases (one `it` per scenario or Examples row; `it.each` for outlines):
2. Shortcuts: `/`, Ctrl+K and Cmd+K open; `/` ignored in input, textarea and contenteditable and not typed into the palette input; Ctrl+K opens from inside an input and calls preventDefault; listeners removed on unmount; Ctrl/Cmd+S in the editor is not affected. 2. Shortcuts: `/`, Ctrl+K and Cmd+K open; `/` ignored in input, textarea and contenteditable and not typed into the palette input; Ctrl+K opens from inside an input and calls preventDefault; listeners removed on unmount; Ctrl/Cmd+S in the editor is not affected.
3. Open focus: input focused; close returns focus to the opener; Cancel on mobile. 3. Open focus: input focused; close returns focus to the opener; Cancel on mobile.
4. Empty query: the 5 Suggested rows in order, first selected, "5 suggestions" in footer and live region; no recent searches stored. 4. Empty query: the 5 Suggested rows in order, first selected, "5 suggestions" in footer and live region; no recent searches stored.
5. "media": 6 endpoint rows of 11, Sections row Media "11 routes", cap line "5 more endpoints. Keep typing to narrow the list.", "12 results", live "12 results, showing 7", highlight marks. 5. "media": 6 endpoint rows of 16, Sections row Media "11 routes", cap line "10 more endpoints. Keep typing to narrow the list.", "17 results", live "17 results, showing 7", highlight marks.
6. "404": 6 rows with "Error" second line, Errors section, cap line "8 more endpoints. Keep typing to narrow the list.", "15 results", live "15 results, showing 7". 6. "404": 6 rows with "Error" second line, Errors section, cap line "17 more endpoints. Keep typing to narrow the list.", "24 results", live "24 results, showing 7".
7. "keep_id": one row, "Body field keep_id", "1 result". "page": GET /v1/site/{key}/sitemap shows "Query parameter page"; GET /v1/article/ matches in its description and has no second line. 7. "keep_id": one row, "Body field keep_id", "1 result". "page": GET /v1/site/{key}/sitemap shows "Query parameter page"; GET /v1/article/ matches in its description and has no second line.
8. "login": dashed chip with lock, weight 400, Session only section row "25 routes, not available to API keys" with lock icon, "2 results". 8. "login": dashed chip with lock, weight 400, Session only section row "37 routes, not available to API keys" with lock icon, "2 results".
9. "xyz": empty copy, "0 results", live "No results". 9. "xyz": empty copy, "0 results", live "No results".
10. Matcher unit table: multi-term AND, case-insensitive, substring, "mdia" no match, extra spaces trimmed, path/description matches first then catalogue order, no search over code samples or response examples. 10. Matcher unit table: multi-term AND, case-insensitive, substring, "mdia" no match, extra spaces trimmed, path/description matches first then catalogue order, no search over code samples or response examples.
11. Keyboard: ArrowDown/ArrowUp wrap, Home, End, typing resets to row 1, Enter selects, Esc closes with no scroll and no hash change. 11. Keyboard: ArrowDown/ArrowUp wrap, Home, End, typing resets to row 1, Enter selects, Esc closes with no scroll and no hash change.
@@ -41,8 +41,8 @@ References: `docs/design/screens/integration.md` "Search (jump-to palette)", `do
|---|---|---| |---|---|---|
| 8a Trigger focused | focus the trigger | | | 8a Trigger focused | focus the trigger | |
| 8b Open, empty | open the palette | 5 Suggested rows | | 8b Open, empty | open the palette | 5 Suggested rows |
| 8c "media" | type "media" | 6 of 11 endpoints, Media section, 12 results | | 8c "media" | type "media" | 6 of 16 endpoints, Media section, 17 results |
| 8d "404", third row selected | type "404", ArrowDown x2 | cap line, 15 results | | 8d "404", third row selected | type "404", ArrowDown x2 | cap line, 24 results |
| 8e "keep_id" | type "keep_id" | 1 result | | 8e "keep_id" | type "keep_id" | 1 result |
| 8f "login" | type "login" | session-only styling | | 8f "login" | type "login" | session-only styling |
| 8g no results | type "xyz" | | | 8g no results | type "xyz" | |
+11 -1
View File
@@ -142,7 +142,17 @@ const snippetTabs = computed(() => [
variant="outline" variant="outline"
class="h-5 w-9 shrink-0 justify-center rounded-none border border-accented px-0 py-0 font-mono text-xs font-medium text-toned ring-0" class="h-5 w-9 shrink-0 justify-center rounded-none border border-accented px-0 py-0 font-mono text-xs font-medium text-toned ring-0"
/> />
<span class="min-w-0 text-pretty text-toned">{{ error.caption }}</span> <span v-if="error.literal" class="min-w-0 text-pretty text-toned"
><code class="font-mono text-xs text-highlighted">{{
error.caption
}}</code
><span v-if="error.tail" class="ml-1.5">{{
translate(error.tail)
}}</span></span
>
<span v-else class="min-w-0 text-pretty text-toned">{{
error.caption
}}</span>
</div> </div>
</div> </div>
</div> </div>
+221 -51
View File
@@ -5,6 +5,7 @@ import {
ERROR_ROWS, ERROR_ROWS,
LOGIN_FAMILY_CAPTION, LOGIN_FAMILY_CAPTION,
NO_CONTENT_RESPONSE, NO_CONTENT_RESPONSE,
OWNER_OR_ADMIN_MESSAGE,
OWNER_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE,
PUBLIC_AUTH_ROWS, PUBLIC_AUTH_ROWS,
RATE_LIMIT_SENTENCE, RATE_LIMIT_SENTENCE,
@@ -29,6 +30,21 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'POST /v1/article/{id}/seo/check', 'POST /v1/article/{id}/seo/check',
], ],
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'], 'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
'Content types': [
'GET /v1/content-type/',
'GET /v1/content-type/{key}',
'POST /v1/content-type/',
'PUT /v1/content-type/{key}',
'DELETE /v1/content-type/{key}',
],
Entries: [
'GET /v1/content-type/{key}/entry',
'POST /v1/content-type/{key}/entry',
'GET /v1/content-type/{key}/entry/{id}',
'PUT /v1/content-type/{key}/entry/{id}',
'DELETE /v1/content-type/{key}/entry/{id}',
],
'Built-in types': ['GET /v1/builtin-type/', 'GET /v1/builtin-type/{key}'],
Media: [ Media: [
'GET /v1/media/', 'GET /v1/media/',
'GET /v1/media/{id}', 'GET /v1/media/{id}',
@@ -42,6 +58,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/media/purge-preview', 'GET /v1/media/purge-preview',
'POST /v1/media/purge', 'POST /v1/media/purge',
], ],
'Sites and authors': ['GET /v1/site/', 'GET /v1/author/'],
'Users and invites': [ 'Users and invites': [
'GET /v1/user/', 'GET /v1/user/',
'PATCH /v1/user/me', 'PATCH /v1/user/me',
@@ -51,8 +68,6 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
'GET /v1/auth/me', 'GET /v1/auth/me',
], ],
Settings: ['GET /v1/settings'], Settings: ['GET /v1/settings'],
Sites: ['GET /v1/site/'],
Authors: ['GET /v1/author/'],
} }
const EXPECTED_SESSION: Record<string, string[]> = { const EXPECTED_SESSION: Record<string, string[]> = {
@@ -61,6 +76,7 @@ const EXPECTED_SESSION: Record<string, string[]> = {
'POST /v1/system/update/check', 'POST /v1/system/update/check',
'POST /v1/system/update/download', 'POST /v1/system/update/download',
'POST /v1/system/update/restart', 'POST /v1/system/update/restart',
'PATCH /v1/settings',
], ],
'Sign in and account': [ 'Sign in and account': [
'POST /v1/auth/login', 'POST /v1/auth/login',
@@ -70,21 +86,32 @@ const EXPECTED_SESSION: Record<string, string[]> = {
'DELETE /v1/auth/register', 'DELETE /v1/auth/register',
'POST /v1/auth/password-reset', 'POST /v1/auth/password-reset',
], ],
'Sites · owner only': [ 'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
'Sites and authors writes': [
'POST /v1/site/', 'POST /v1/site/',
'PATCH /v1/site/{id}', 'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default', 'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}', 'DELETE /v1/site/{id}',
],
'Authors · owner and admin': [
'POST /v1/author/', 'POST /v1/author/',
'PATCH /v1/author/{id}', 'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}', 'DELETE /v1/author/{id}',
'POST /v1/author/me', 'POST /v1/author/me',
], ],
'Settings · owner only': ['PATCH /v1/settings'], 'Image variants': [
'Activity log': ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'], 'GET /v1/image-variants/',
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'], 'POST /v1/image-variants/',
'PUT /v1/image-variants/{name}',
'PATCH /v1/image-variants/{name}',
'DELETE /v1/image-variants/{name}',
'POST /v1/image-variants/{name}/purge',
'GET /v1/image-variants/cache',
'POST /v1/image-variants/cache/purge',
'GET /v1/media/{id}/variants',
'POST /v1/media/{id}/variants',
'POST /v1/media/{id}/variants/{name}',
'DELETE /v1/media/{id}/variants/{name}',
],
Activity: ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
} }
const EXPECTED_PUBLIC = [ const EXPECTED_PUBLIC = [
@@ -185,6 +212,13 @@ const ME_KEYS = [
'role', 'role',
] ]
function sampleKey(path: string) {
if (path.startsWith('/v1/content-type')) {
return 'events'
}
return path.startsWith('/v1/builtin-type') ? 'author' : 'renew-blog'
}
const label = (item: { method: string; path: string }) => `${item.method} ${item.path}` const label = (item: { method: string; path: string }) => `${item.method} ${item.path}`
function find(route: string) { function find(route: string) {
@@ -208,29 +242,37 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
} }
describe('Catalogue routes', () => { describe('Catalogue routes', () => {
it('holds exactly the 30 key-callable routes in 7 groups, in order', () => { it('holds exactly the 42 key-callable routes in 9 groups, in order', () => {
expect(API_GROUPS.map((group) => group.title)).toEqual([ expect(API_GROUPS.map((group) => group.title)).toEqual([
'Articles', 'Articles',
'Search and sharing', 'Search and sharing',
'Content types',
'Entries',
'Built-in types',
'Media', 'Media',
'Sites and authors',
'Users and invites', 'Users and invites',
'Settings', 'Settings',
'Sites',
'Authors',
]) ])
expect( expect(
Object.fromEntries( Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]), API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
), ),
).toEqual(EXPECTED_GROUPS) ).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(30) expect(allEndpoints()).toHaveLength(42)
}) })
it('holds the 25 session-only rows in 7 sub-groups', () => { it('counts the routes of each group', () => {
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
8, 2, 5, 5, 2, 11, 2, 6, 1,
])
})
it('holds the 37 session-only rows in 6 sub-groups', () => {
expect( expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])), Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION) ).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(25) expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(37)
}) })
it('holds the 6 public auth flow rows', () => { it('holds the 6 public auth flow rows', () => {
@@ -252,7 +294,7 @@ describe('Catalogue routes', () => {
), ),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const), ...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
] ]
expect(described).toHaveLength(7 + 30 + 25 + 6) expect(described).toHaveLength(9 + 42 + 37 + 6)
const blank = described.filter(([, description]) => description.trim() === '') const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([]) expect(blank.map(([name]) => name)).toEqual([])
}) })
@@ -272,7 +314,7 @@ describe('Catalogue completeness', () => {
'%s has method, path, chips, tables and examples', '%s has method, path, chips, tables and examples',
(_route, endpoint) => { (_route, endpoint) => {
const examples = endpointExamples(endpoint, ORIGIN) const examples = endpointExamples(endpoint, ORIGIN)
expect(['GET', 'POST', 'PATCH', 'DELETE']).toContain(endpoint.method) expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).toContain(endpoint.method)
expect(endpoint.path.startsWith('/v1/')).toBe(true) expect(endpoint.path.startsWith('/v1/')).toBe(true)
expect(['Public', 'Verified user', 'Owner or admin', 'Owner only']).toContain( expect(['Public', 'Verified user', 'Owner or admin', 'Owner only']).toContain(
endpoint.access, endpoint.access,
@@ -280,7 +322,7 @@ describe('Catalogue completeness', () => {
expect(examples.fetch).toContain(`${ORIGIN}/v1/`) expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`) expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain( expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{key}', 'renew-blog').replace('{id}', String(endpoint.pathId))}"`, `"${ORIGIN}${endpoint.path.replace('{key}', sampleKey(endpoint.path)).replace('{id}', String(endpoint.pathId))}"`,
) )
expect(examples.response).toBe(endpoint.response) expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('') expect(examples.response.trim()).not.toBe('')
@@ -366,12 +408,17 @@ describe('SEO output routes', () => {
}) })
describe('Owner or admin purge routes', () => { describe('Owner or admin purge routes', () => {
it('gives Owner or admin to the four purge routes only', () => { it('gives Owner or admin to the content type writes, the built-in types and the four purge routes', () => {
expect( expect(
allEndpoints() allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner or admin') .filter((endpoint) => endpoint.access === 'Owner or admin')
.map(label), .map(label),
).toEqual([ ).toEqual([
'POST /v1/content-type/',
'PUT /v1/content-type/{key}',
'DELETE /v1/content-type/{key}',
'GET /v1/builtin-type/',
'GET /v1/builtin-type/{key}',
'GET /v1/media/purge-settings', 'GET /v1/media/purge-settings',
'PATCH /v1/media/purge-settings', 'PATCH /v1/media/purge-settings',
'GET /v1/media/purge-preview', 'GET /v1/media/purge-preview',
@@ -449,9 +496,10 @@ describe('Response samples use the real domain JSON keys', () => {
]) ])
}) })
it('adds sites_updated to the purge result', () => { it('gives the purge result exactly the seven real keys', () => {
expect(keysOf(parsed(find('POST /v1/media/purge')))).toEqual([ expect(keysOf(parsed(find('POST /v1/media/purge')))).toEqual([
'articles_updated', 'articles_updated',
'authors_updated',
'failed_groups', 'failed_groups',
'groups_purged', 'groups_purged',
'groups_total', 'groups_total',
@@ -742,37 +790,24 @@ describe('Per-endpoint error captions use the real strings', () => {
describe('Session only rows', () => { describe('Session only rows', () => {
const rows = SESSION_GROUPS.flatMap((group) => group.rows) const rows = SESSION_GROUPS.flatMap((group) => group.rows)
it('claims 403 only on verify, DELETE register, sites, settings, activity, api-key and system rows', () => { it('claims 403 on every row except login, logout, register and password reset', () => {
expect(rows.filter((row) => row.answers403).map(label)).toEqual([ const excluded = [
'GET /v1/system/update', 'POST /v1/auth/login',
'POST /v1/system/update/check', 'POST /v1/auth/logout',
'POST /v1/system/update/download', 'POST /v1/auth/register',
'POST /v1/system/update/restart', 'POST /v1/auth/password-reset',
'POST /v1/auth/verify', ]
'DELETE /v1/auth/register', expect(rows.filter((row) => row.answers403).map(label)).toEqual(
'POST /v1/site/', rows.map(label).filter((route) => !excluded.includes(route)),
'PATCH /v1/site/{id}', )
'PATCH /v1/site/{id}/default', expect(rows.filter((row) => row.answers403)).toHaveLength(33)
'DELETE /v1/site/{id}',
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',
'GET /v1/activity/export',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
}) })
it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => { it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => {
const group = SESSION_GROUPS.find( const group = SESSION_GROUPS.find(
(candidate) => candidate.title === 'Authors · owner and admin', (candidate) => candidate.title === 'Sites and authors writes',
) )
expect(group?.rows.map(label)).toEqual([ expect(group?.rows.map(label).filter((route) => route.includes('/v1/author'))).toEqual([
'POST /v1/author/', 'POST /v1/author/',
'PATCH /v1/author/{id}', 'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}', 'DELETE /v1/author/{id}',
@@ -801,37 +836,52 @@ describe('Session only rows', () => {
}) })
describe('Errors table', () => { describe('Errors table', () => {
it('lists the real statuses and messages', () => { it('lists the real statuses, messages and literal codes', () => {
expect(ERROR_ROWS.map((row) => [row.status, row.messages])).toEqual([ expect(ERROR_ROWS.map((row) => [row.status, row.messages, row.codes ?? []])).toEqual([
[ [
400, 400,
[ [
'The request cannot be processed due to a mismatch in the format of the body.', 'The request cannot be processed due to a mismatch in the format of the body.',
'The expiry must be a date in the future.', 'The expiry must be a date in the future.',
], ],
[],
], ],
[401, ['You must be logged in to access this resource.']], [401, ['You must be logged in to access this resource.'], []],
[ [
403, 403,
[ [
SESSION_REQUIRED_MESSAGE, SESSION_REQUIRED_MESSAGE,
'Only the owner can do this.', OWNER_OR_ADMIN_MESSAGE,
OWNER_REQUIRED_MESSAGE,
'You must verify your account to access this resource.', 'You must verify your account to access this resource.',
], ],
[],
], ],
[ [
404, 404,
['Article not found', 'Image not found', 'Invite not found', 'API key not found'], ['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
['content_type_not_found', 'entry_not_found', 'builtin_type_not_found'],
], ],
[ [
409, 409,
[ [
"There's already a pending invite for this address.", "There's already a pending invite for this address.",
'This invite has already been accepted.', 'This invite has already been accepted.',
'Some of the images to merge changed. Refresh and try again.',
'This address already has an account.',
],
[
'content_type_key_taken',
'content_type_has_entries',
'content_type_referenced',
'schema_incompatible',
'invalid_entry: <reason>',
'slug_taken',
], ],
], ],
[429, ['Too many requests. Please try again later.']], [422, [], ['invalid_entry: <reason>']],
[500, ['Internal server error']], [429, ['Too many requests. Please try again later.'], []],
[500, ['Internal server error'], []],
]) ])
}) })
}) })
@@ -873,3 +923,123 @@ describe('Examples', () => {
expect(API_KEY_PLACEHOLDER.startsWith('rcms_')).toBe(true) expect(API_KEY_PLACEHOLDER.startsWith('rcms_')).toBe(true)
}) })
}) })
describe('Custom content types round', () => {
const group = (key: string) => API_GROUPS.find((candidate) => candidate.key === key)!
it('gives the Content types group its field types table and sample field', () => {
const types = group('ctypes')
expect(types.fieldTypes?.map((row) => row.type)).toEqual([
'text',
'richtext',
'number',
'boolean',
'date',
'select',
'media',
'relation',
])
expect(JSON.parse(types.snippet!.code)).toEqual({
key: 'capacity',
label: 'Capacity',
type: 'number',
required: true,
unique: false,
default: null,
min: 1,
max: 5000,
options: null,
relation: null,
})
})
it('answers an entry list as an object and an entry with media keyed by field key', () => {
expect(keysOf(parsed(find('GET /v1/content-type/{key}/entry')))).toEqual([
'items',
'page',
'size',
'total',
])
const entry = parsed(find('GET /v1/content-type/{key}/entry/{id}'))
expect(keysOf(entry)).toEqual(
[
'id',
'content_type',
'data',
'created_by',
'updated_by',
'created_at',
'updated_at',
'media',
'relations',
].sort(),
)
expect(Object.keys(entry.media)).toEqual(['cover'])
})
it('answers a built-in type with key, name and fields and no icon', () => {
for (const route of ['GET /v1/builtin-type/{key}']) {
expect(keysOf(parsed(find(route)))).toEqual(['fields', 'key', 'name'])
}
for (const item of parsed(find('GET /v1/builtin-type/'))) {
expect(keysOf(item)).toEqual(['fields', 'key', 'name'])
}
})
it('marks the new codes as literal bodies and keeps the 409 and 422 entry errors', () => {
const literalErrors = (route: string) =>
find(route)
.errors.filter((error) => error.literal)
.map((error) => `${error.status} ${error.caption}`)
expect(literalErrors('POST /v1/content-type/')).toEqual(['409 content_type_key_taken'])
expect(literalErrors('PUT /v1/content-type/{key}')).toEqual([
'404 content_type_not_found',
'409 schema_incompatible',
])
expect(literalErrors('DELETE /v1/content-type/{key}')).toEqual([
'404 content_type_not_found',
'409 content_type_has_entries',
'409 content_type_referenced',
])
expect(literalErrors('POST /v1/content-type/{key}/entry')).toEqual([
'404 content_type_not_found',
'409 invalid_entry: title must be unique',
'422 invalid_entry: capacity must be at most 5000',
])
expect(literalErrors('GET /v1/builtin-type/{key}')).toEqual(['404 builtin_type_not_found'])
})
it('takes the entry data and indexes the events keys as body fields', () => {
for (const route of [
'POST /v1/content-type/{key}/entry',
'PUT /v1/content-type/{key}/entry/{id}',
]) {
expect(find(route).body.map((field) => field.name)).toEqual(['data'])
expect(find(route).searchKeys).toEqual([
'title',
'starts_on',
'capacity',
'online',
'category',
'cover',
])
}
})
it('resolves the sample keys in the examples', () => {
expect(endpointExamples(find('GET /v1/content-type/{key}'), ORIGIN).curl).toContain(
`${ORIGIN}/v1/content-type/events"`,
)
expect(endpointExamples(find('GET /v1/builtin-type/{key}'), ORIGIN).curl).toContain(
`${ORIGIN}/v1/builtin-type/author"`,
)
expect(
endpointExamples(find('PUT /v1/content-type/{key}/entry/{id}'), ORIGIN).fetch,
).toContain("method: 'PUT'")
})
it('pretty prints the body of the content type writes', () => {
const created = endpointExamples(find('POST /v1/content-type/'), ORIGIN)
expect(created.curl).toContain('-d \'{\n "key": "events"')
})
})
+564 -114
View File
@@ -1,4 +1,4 @@
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE' export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only' export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only'
export interface ApiField { export interface ApiField {
@@ -11,6 +11,8 @@ export interface ApiField {
export interface ApiError { export interface ApiError {
status: number status: number
caption: string caption: string
literal?: boolean
tail?: string
} }
export type BodyKind = 'none' | 'json' | 'multipart' export type BodyKind = 'none' | 'json' | 'multipart'
@@ -30,12 +32,29 @@ export interface ApiEndpoint {
response: string response: string
status: number status: number
rateLimited: boolean rateLimited: boolean
prettyBody: boolean
searchKeys: string[]
}
export interface FieldTypeRow {
type: string
value: string
rules: string
}
export interface GroupSnippet {
key: string
label: string
code: string
} }
export interface ApiGroup { export interface ApiGroup {
key: string key: string
title: string title: string
description: string description: string
note?: string
fieldTypes?: FieldTypeRow[]
snippet?: GroupSnippet
endpoints: ApiEndpoint[] endpoints: ApiEndpoint[]
} }
@@ -61,6 +80,7 @@ export interface ErrorRow {
status: number status: number
meaning: string meaning: string
messages: string[] messages: string[]
codes?: string[]
note?: string note?: string
} }
@@ -75,6 +95,8 @@ export const NO_CONTENT_RESPONSE = '204 No Content'
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.' export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.' export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.'
export const OWNER_OR_ADMIN_MESSAGE = 'Only owners and admins can do this.' export const OWNER_OR_ADMIN_MESSAGE = 'Only owners and admins can do this.'
export const IMAGES_AFTER_GROUP = 'sites'
export const FIELD_TYPES_ANCHOR = 'ct-fields'
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.' export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
export const RATE_LIMIT_SENTENCE = 'This route is rate limited. Too many requests answer 429.' export const RATE_LIMIT_SENTENCE = 'This route is rate limited. Too many requests answer 429.'
@@ -302,6 +324,8 @@ const article = (overrides: Record<string, unknown> = {}) => ({
}) })
const SAMPLE_SITE_KEY = 'renew-blog' const SAMPLE_SITE_KEY = 'renew-blog'
const SAMPLE_CONTENT_TYPE_KEY = 'events'
const SAMPLE_BUILTIN_TYPE_KEY = 'author'
const resolvedSeo = () => ({ const resolvedSeo = () => ({
title: `${ARTICLE_TITLE} | Renew`, title: `${ARTICLE_TITLE} | Renew`,
@@ -452,6 +476,127 @@ const author = (overrides: Record<string, unknown> = {}) => ({
...overrides, ...overrides,
}) })
const fieldOf = (
key: string,
label: string,
type: string,
overrides: Record<string, unknown> = {},
) => ({
key,
label,
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
relation: null,
...overrides,
})
const eventFields = [
fieldOf('title', 'Title', 'text', { required: true, min: 3, max: 120 }),
fieldOf('starts_on', 'Starts on', 'date', { required: true }),
fieldOf('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }),
fieldOf('online', 'Online', 'boolean'),
fieldOf('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }),
fieldOf('cover', 'Cover', 'media'),
]
const eventType = {
id: 2,
key: 'events',
name: 'Events',
icon: 'calendar',
fields: eventFields,
created_at: '2026-09-02T08:30:00Z',
updated_at: '2026-10-20T14:05:00Z',
}
const eventData = {
title: 'Open day',
starts_on: '2026-11-14',
capacity: 120,
online: false,
category: 'Open day',
cover: 4,
}
const eventEntry = (data: Record<string, unknown> = eventData) => ({
id: 31,
content_type: 'events',
data,
created_by: 1,
updated_by: 1,
created_at: '2026-10-20T14:05:00Z',
updated_at: '2026-10-22T09:41:00Z',
media: {
cover: {
id: 4,
filename: 'north-wall.jpg',
width: 1600,
height: 1067,
size: 3900000,
thumbnail_url: '/static/uploadedImages/3f9c-thumb.jpg',
},
},
relations: {},
})
const articleBuiltinFields = [
fieldOf('title', 'Title', 'text', { required: true, min: 3, max: 200 }),
fieldOf('slug', 'Slug', 'text', { required: true, unique: true }),
fieldOf('body', 'Body', 'richtext'),
]
const authorBuiltinFields = [
fieldOf('name', 'Name', 'text', { required: true, min: 1, max: 100 }),
fieldOf('slug', 'Slug', 'text', { required: true, unique: true }),
]
const FIELD_TYPE_ROWS: FieldTypeRow[] = [
{
type: 'text',
value: 'string',
rules: 'min/max = length in characters. Can be unique.',
},
{
type: 'richtext',
value: 'string (Markdown)',
rules: 'min/max = length. Cannot be unique.',
},
{ type: 'number', value: 'number', rules: 'min/max = value. Can be unique.' },
{ type: 'boolean', value: 'true/false', rules: 'No min/max. Cannot be unique.' },
{
type: 'date',
value: 'string "YYYY-MM-DD"',
rules: 'min/max = earliest/latest date. Can be unique.',
},
{
type: 'select',
value: 'one string from `options` (1 to 50 choices)',
rules: 'Can be unique.',
},
{
type: 'media',
value: 'the numeric id of an uploaded image (see Media)',
rules: '\u2014',
},
{
type: 'relation',
value: 'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many',
rules: '\u2014',
},
]
const NAME_RULE = 'Up to 100 characters.'
const FIELDS_RULE = '1 to 50 fields, in order. See Field types.'
const ENTRY_DATA_RULE =
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover."
const ENTRY_SEARCH_KEYS = ['title', 'starts_on', 'capacity', 'online', 'category', 'cover']
const INVALID_PAGE_SIZE_SORT = 'Invalid page, size or sort.'
const me = { const me = {
id: 1, id: 1,
username: 'marta', username: 'marta',
@@ -475,10 +620,27 @@ const invite = (overrides: Record<string, unknown> = {}) => ({
const e = (status: number, caption: string): ApiError => ({ status, caption }) const e = (status: number, caption: string): ApiError => ({ status, caption })
const literal = (status: number, caption: string, tail?: string): ApiError => ({
status,
caption,
literal: true,
...(tail ? { tail } : {}),
})
const e401 = e(401, UNAUTHORIZED)
const eOwnerOrAdmin = e(403, OWNER_OR_ADMIN_MESSAGE)
const contentTypeNotFound = literal(404, 'content_type_not_found')
const entryNotFound = literal(404, 'entry_not_found')
const invalidEntryUnique = literal(409, 'invalid_entry: title must be unique')
const invalidEntryRange = literal(422, 'invalid_entry: capacity must be at most 5000')
const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)] const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)]
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)] const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
const verifiedAfterLogin = (errors: ApiError[]) =>
errors.flatMap((error) => (error.status === 401 ? [error, e(403, VERIFY_REQUIRED)] : [error]))
const ownerOrAdminErrors = (...own: ApiError[]) => [ const ownerOrAdminErrors = (...own: ApiError[]) => [
...own, ...own,
e(401, UNAUTHORIZED), e(401, UNAUTHORIZED),
@@ -545,6 +707,8 @@ function endpoint(
response, response,
status: response === NO_CONTENT_RESPONSE ? 204 : 200, status: response === NO_CONTENT_RESPONSE ? 204 : 200,
rateLimited: false, rateLimited: false,
prettyBody: false,
searchKeys: [],
...extra, ...extra,
} }
} }
@@ -843,6 +1007,255 @@ export const API_GROUPS: ApiGroup[] = [
), ),
], ],
}, },
{
key: 'ctypes',
title: 'Content types',
description:
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.',
fieldTypes: FIELD_TYPE_ROWS,
snippet: { key: 'field', label: 'Field object', code: json(eventFields[2]) },
endpoints: [
endpoint(
'ct-list',
'GET',
'/v1/content-type/',
'List all custom content types.',
'Verified user',
verifiedAfterLogin([e401]),
json([{ ...eventType, fields: eventFields.slice(0, 3) }]),
),
endpoint(
'ct-get',
'GET',
'/v1/content-type/{key}',
'Get one content type with its fields.',
'Verified user',
verifiedAfterLogin([e401, contentTypeNotFound]),
json(eventType),
),
endpoint(
'ct-create',
'POST',
'/v1/content-type/',
'Create a content type.',
'Owner or admin',
[
e(400, BODY_MISMATCH),
e401,
eOwnerOrAdmin,
literal(409, 'content_type_key_taken'),
],
json(eventType),
{
status: 201,
prettyBody: true,
bodyKind: 'json',
body: [
{
name: 'key',
type: 'string',
required: true,
rule: "2 to 40 characters. Lowercase letters, digits and hyphens. Can't be changed later.",
},
{ name: 'name', type: 'string', required: true, rule: NAME_RULE },
{ name: 'icon', type: 'string', required: false, rule: 'Optional.' },
{ name: 'fields', type: 'object[]', required: true, rule: FIELDS_RULE },
],
bodySample: {
key: 'events',
name: 'Events',
icon: 'calendar',
fields: eventFields.slice(0, 3),
},
},
),
endpoint(
'ct-update',
'PUT',
'/v1/content-type/{key}',
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.",
'Owner or admin',
[
e(400, BODY_MISMATCH),
e401,
eOwnerOrAdmin,
contentTypeNotFound,
literal(
409,
'schema_incompatible',
"Some existing entries don't fit the new fields. Nothing was changed.",
),
],
json(eventType),
{
prettyBody: true,
bodyKind: 'json',
body: [
{ name: 'name', type: 'string', required: true, rule: NAME_RULE },
{ name: 'icon', type: 'string', required: false, rule: 'Optional.' },
{ name: 'fields', type: 'object[]', required: true, rule: FIELDS_RULE },
],
bodySample: {
name: 'Events',
icon: 'calendar',
fields: eventFields.slice(0, 3),
},
},
),
endpoint(
'ct-delete',
'DELETE',
'/v1/content-type/{key}',
'Delete a content type. Only possible when it has no entries and no relation field points at it.',
'Owner or admin',
[
e401,
eOwnerOrAdmin,
contentTypeNotFound,
literal(409, 'content_type_has_entries'),
literal(409, 'content_type_referenced'),
],
NO_CONTENT_RESPONSE,
),
],
},
{
key: 'entries',
title: 'Entries',
description:
'Read and write the content of a custom type. Any verified user can write entries.',
note: "An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).",
snippet: { key: 'entry', label: 'Entry', code: json(eventEntry()) },
endpoints: [
endpoint(
'en-list',
'GET',
'/v1/content-type/{key}/entry',
'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.',
'Verified user',
verifiedAfterLogin([e(400, INVALID_PAGE_SIZE_SORT), e401, contentTypeNotFound]),
json({ items: [eventEntry()], total: 1, page: 1, size: 25 }),
{
query: [
{
name: 'page',
type: 'integer',
required: false,
rule: '1 or higher. Default 1.',
},
{
name: 'size',
type: 'integer',
required: false,
rule: 'One of 5, 25, 50 or 100. Default 25.',
},
{
name: 'sort',
type: 'string',
required: false,
rule: 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.',
},
],
},
),
endpoint(
'en-create',
'POST',
'/v1/content-type/{key}/entry',
'Create an entry.',
'Verified user',
verifiedAfterLogin([
e(400, BODY_MISMATCH),
e401,
contentTypeNotFound,
invalidEntryUnique,
invalidEntryRange,
]),
json(eventEntry()),
{
status: 201,
bodyKind: 'json',
searchKeys: ENTRY_SEARCH_KEYS,
body: [{ name: 'data', type: 'object', required: true, rule: ENTRY_DATA_RULE }],
bodySample: { data: eventData },
},
),
endpoint(
'en-get',
'GET',
'/v1/content-type/{key}/entry/{id}',
'Get one entry.',
'Verified user',
verifiedAfterLogin([e(400, ID_FORMAT), e401, entryNotFound, contentTypeNotFound]),
json(eventEntry()),
{ pathId: 31 },
),
endpoint(
'en-update',
'PUT',
'/v1/content-type/{key}/entry/{id}',
'Replace the data of an entry.',
'Verified user',
verifiedAfterLogin([
e(400, BODY_MISMATCH),
e401,
entryNotFound,
contentTypeNotFound,
invalidEntryUnique,
invalidEntryRange,
]),
json(eventEntry({ ...eventData, capacity: 150 })),
{
pathId: 31,
bodyKind: 'json',
searchKeys: ENTRY_SEARCH_KEYS,
body: [{ name: 'data', type: 'object', required: true, rule: ENTRY_DATA_RULE }],
bodySample: { data: { ...eventData, capacity: 150 } },
},
),
endpoint(
'en-delete',
'DELETE',
'/v1/content-type/{key}/entry/{id}',
'Delete an entry.',
'Verified user',
verifiedAfterLogin([e401, entryNotFound, contentTypeNotFound]),
NO_CONTENT_RESPONSE,
{ pathId: 31 },
),
],
},
{
key: 'builtin',
title: 'Built-in types',
description:
'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.',
note: 'Built-in types are read-only: they cannot be changed through the API.',
endpoints: [
endpoint(
'bt-list',
'GET',
'/v1/builtin-type/',
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.',
'Owner or admin',
[e401, eOwnerOrAdmin],
json([
{ key: 'article', name: 'Articles', fields: articleBuiltinFields },
{ key: 'author', name: 'Authors', fields: authorBuiltinFields },
]),
),
endpoint(
'bt-get',
'GET',
'/v1/builtin-type/{key}',
'Get one built-in type. Keys are article, author, site, media and user.',
'Owner or admin',
[e401, eOwnerOrAdmin, literal(404, 'builtin_type_not_found')],
json({ key: 'author', name: 'Authors', fields: authorBuiltinFields }),
),
],
},
{ {
key: 'media', key: 'media',
title: 'Media', title: 'Media',
@@ -1060,7 +1473,7 @@ export const API_GROUPS: ApiGroup[] = [
'med-purge-run', 'med-purge-run',
'POST', 'POST',
'/v1/media/purge', '/v1/media/purge',
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.', 'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged.',
'Owner or admin', 'Owner or admin',
ownerOrAdminErrors( ownerOrAdminErrors(
e(400, BODY_MISMATCH), e(400, BODY_MISMATCH),
@@ -1073,6 +1486,7 @@ export const API_GROUPS: ApiGroup[] = [
merged: 2, merged: 2,
articles_updated: 5, articles_updated: 5,
sites_updated: 1, sites_updated: 1,
authors_updated: 0,
failed_groups: 0, failed_groups: 0,
}), }),
{ {
@@ -1090,6 +1504,89 @@ export const API_GROUPS: ApiGroup[] = [
), ),
], ],
}, },
{
key: 'sites',
title: 'Sites and authors',
description:
'Read the sites and authors your content belongs to. Creating and changing them needs a session.',
endpoints: [
endpoint(
'site-list',
'GET',
'/v1/site/',
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.',
'Verified user',
verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]),
json([
site(),
site({
id: 2,
name: 'Renew shop',
key: 'renew-shop',
base_url: 'https://shop.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 4,
site_name: '',
title_template: '%title% - %site%',
default_description: '',
}),
]),
{
query: [
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.',
},
],
},
),
endpoint(
'author-list',
'GET',
'/v1/author/',
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.',
'Verified user',
verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]),
json({
items: [
author(),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
website: null,
user_id: null,
is_linked: false,
article_count: 3,
}),
],
total: 2,
page: 1,
size: 25,
}),
{
query: [
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched in the name or the slug.',
},
],
},
),
],
},
{ {
key: 'users', key: 'users',
title: 'Users and invites', title: 'Users and invites',
@@ -1229,97 +1726,6 @@ export const API_GROUPS: ApiGroup[] = [
), ),
], ],
}, },
{
key: 'sites',
title: 'Sites',
description:
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
endpoints: [
endpoint(
'site-list',
'GET',
'/v1/site/',
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.',
'Verified user',
verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]),
json([
site(),
site({
id: 2,
name: 'Renew shop',
key: 'renew-shop',
base_url: 'https://shop.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 4,
site_name: '',
title_template: '%title% - %site%',
default_description: '',
}),
]),
{
query: [
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.',
},
],
},
),
],
},
{
key: 'authors',
title: 'Authors',
description:
'The people credited on articles. Any verified user can read them; changes are made in the browser by an owner or admin.',
endpoints: [
endpoint(
'author-list',
'GET',
'/v1/author/',
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.',
'Verified user',
verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]),
json({
items: [
author(),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
website: null,
user_id: null,
is_linked: false,
article_count: 3,
}),
],
total: 2,
page: 1,
size: 25,
}),
{
query: [
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched in the name or the slug.',
},
],
},
),
],
},
] ]
const row = ( const row = (
@@ -1342,6 +1748,7 @@ export const SESSION_GROUPS: SessionGroup[] = [
row('POST', '/v1/system/update/check', 'Check for update'), row('POST', '/v1/system/update/check', 'Check for update'),
row('POST', '/v1/system/update/download', 'Download update'), row('POST', '/v1/system/update/download', 'Download update'),
row('POST', '/v1/system/update/restart', 'Restart'), row('POST', '/v1/system/update/restart', 'Restart'),
row('PATCH', '/v1/settings', 'Change the activity log retention'),
], ],
}, },
{ {
@@ -1356,17 +1763,20 @@ export const SESSION_GROUPS: SessionGroup[] = [
], ],
}, },
{ {
title: 'Sites · owner only', title: 'API key management',
rows: [
row('GET', '/v1/api-key', 'List keys'),
row('POST', '/v1/api-key', 'Create a key'),
row('DELETE', '/v1/api-key/{id}', 'Revoke a key'),
],
},
{
title: 'Sites and authors writes',
rows: [ rows: [
row('POST', '/v1/site/', 'Create a site'), row('POST', '/v1/site/', 'Create a site'),
row('PATCH', '/v1/site/{id}', 'Edit a site'), row('PATCH', '/v1/site/{id}', 'Edit a site'),
row('PATCH', '/v1/site/{id}/default', 'Make a site the default'), row('PATCH', '/v1/site/{id}/default', 'Make a site the default'),
row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'), row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'),
],
},
{
title: 'Authors · owner and admin',
rows: [
row('POST', '/v1/author/', 'Create an author'), row('POST', '/v1/author/', 'Create an author'),
row( row(
'PATCH', 'PATCH',
@@ -1382,25 +1792,30 @@ export const SESSION_GROUPS: SessionGroup[] = [
], ],
}, },
{ {
title: 'Settings · owner only', title: 'Image variants',
rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')], rows: [
row('GET', '/v1/image-variants/', 'List variants'),
row('POST', '/v1/image-variants/', 'Create a variant'),
row('PUT', '/v1/image-variants/{name}', 'Replace a variant'),
row('PATCH', '/v1/image-variants/{name}', 'Update a variant'),
row('DELETE', '/v1/image-variants/{name}', 'Delete a variant'),
row('POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'),
row('GET', '/v1/image-variants/cache', 'Image cache status'),
row('POST', '/v1/image-variants/cache/purge', 'Purge the image cache'),
row('GET', '/v1/media/{id}/variants', 'List the variants of an image'),
row('POST', '/v1/media/{id}/variants', 'Add variants to an image'),
row('POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'),
row('DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image'),
],
}, },
{ {
title: 'Activity log', title: 'Activity',
rows: [ rows: [
row('GET', '/v1/activity', 'List activity events'), row('GET', '/v1/activity', 'List activity events'),
row('GET', '/v1/activity/summary', 'Activity summary, owner and admin'), row('GET', '/v1/activity/summary', 'Activity summary, owner and admin'),
row('GET', '/v1/activity/export', 'Export activity as CSV, owner and admin'), row('GET', '/v1/activity/export', 'Export activity as CSV, owner and admin'),
], ],
}, },
{
title: 'API key management',
rows: [
row('GET', '/v1/api-key', 'List keys'),
row('POST', '/v1/api-key', 'Create a key'),
row('DELETE', '/v1/api-key/{id}', 'Revoke a key'),
],
},
] ]
export const PUBLIC_AUTH_ROWS: PublicRow[] = [ export const PUBLIC_AUTH_ROWS: PublicRow[] = [
@@ -1442,13 +1857,20 @@ export const ERROR_ROWS: ErrorRow[] = [
{ {
status: 403, status: 403,
meaning: 'Forbidden', meaning: 'Forbidden',
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED], messages: [
SESSION_REQUIRED_MESSAGE,
OWNER_OR_ADMIN_MESSAGE,
OWNER_REQUIRED_MESSAGE,
VERIFY_REQUIRED,
],
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.', note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
}, },
{ {
status: 404, status: 404,
meaning: 'Not found', meaning: 'Not found',
messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'], messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
codes: ['content_type_not_found', 'entry_not_found', 'builtin_type_not_found'],
note: 'Text in code style is the literal body of the response, not a sentence.',
}, },
{ {
status: 409, status: 409,
@@ -1456,7 +1878,25 @@ export const ERROR_ROWS: ErrorRow[] = [
messages: [ messages: [
"There's already a pending invite for this address.", "There's already a pending invite for this address.",
'This invite has already been accepted.', 'This invite has already been accepted.',
'Some of the images to merge changed. Refresh and try again.',
'This address already has an account.',
], ],
codes: [
'content_type_key_taken',
'content_type_has_entries',
'content_type_referenced',
'schema_incompatible',
'invalid_entry: <reason>',
'slug_taken',
],
note: 'On an entry, `invalid_entry: <reason>` is a 409 only when a unique field clashes. Any other field error is a 422.',
},
{
status: 422,
meaning: 'Unprocessable',
messages: [],
codes: ['invalid_entry: <reason>'],
note: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.',
}, },
{ {
status: 429, status: 429,
@@ -1474,8 +1914,15 @@ export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\
export const AUTH_EXAMPLE_PATH = '/v1/auth/me' export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
function sampleKey(path: string) {
if (path.startsWith('/v1/content-type')) {
return SAMPLE_CONTENT_TYPE_KEY
}
return path.startsWith('/v1/builtin-type') ? SAMPLE_BUILTIN_TYPE_KEY : SAMPLE_SITE_KEY
}
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) { function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
const keyed = item.path.replace('{key}', SAMPLE_SITE_KEY) const keyed = item.path.replace('{key}', sampleKey(item.path))
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId)) return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
} }
@@ -1485,12 +1932,15 @@ export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExa
const isJson = item.bodyKind === 'json' && item.bodySample !== null const isJson = item.bodyKind === 'json' && item.bodySample !== null
const isForm = item.bodyKind === 'multipart' const isForm = item.bodyKind === 'multipart'
const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}` const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
const bodyJson = item.prettyBody
? JSON.stringify(item.bodySample, null, 2)
: JSON.stringify(item.bodySample)
const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`] const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`]
if (!isPublic) curlParts.push(` -H "${authHeader}"`) if (!isPublic) curlParts.push(` -H "${authHeader}"`)
if (isJson) { if (isJson) {
curlParts.push(` -H "Content-Type: application/json"`) curlParts.push(` -H "Content-Type: application/json"`)
curlParts.push(` -d '${JSON.stringify(item.bodySample)}'`) curlParts.push(` -d '${bodyJson}'`)
} }
if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`) if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`)
@@ -1501,7 +1951,7 @@ export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExa
isJson ? `'Content-Type': 'application/json'` : null, isJson ? `'Content-Type': 'application/json'` : null,
].filter((header) => header !== null) ].filter((header) => header !== null)
if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`) if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`)
if (isJson) options.push(`body: JSON.stringify(${JSON.stringify(item.bodySample)})`) if (isJson) options.push(`body: JSON.stringify(${bodyJson.replace(/\n/g, '\n ')})`)
if (isForm) options.push('body: form') if (isForm) options.push('body: form')
const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : '' const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : ''
+94 -4
View File
@@ -950,8 +950,8 @@ export const messages = {
'API reference': 'Référence de l’API', 'API reference': 'Référence de l’API',
'Everything a developer needs to build on RenewCMS.': 'Everything a developer needs to build on RenewCMS.':
'Tout ce dont une personne développeuse a besoin pour construire sur RenewCMS.', 'Tout ce dont une personne développeuse a besoin pour construire sur RenewCMS.',
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.': 'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.':
'RenewCMS est headless : il stocke votre contenu et votre propre code l’affiche. Il y a deux façons d’y accéder. L’accès public en lecture seule permet à un site de lire les articles publiés. Les clés d’API permettent à un script ou à un service de gérer les articles, les médias et les utilisateurs sans le back-office.', 'RenewCMS est headless : il stocke votre contenu et votre propre code l’affiche. Il y a deux façons d’y accéder. L’accès public en lecture seule permet à un site de lire les articles publiés. Les clés d’API permettent à un script ou à un service de gérer les articles, les médias et les utilisateurs, ainsi que les types de contenu et les entrées, sans le back-office.',
'No key': 'Sans clé', 'No key': 'Sans clé',
'No key needed. Two read-only routes, and only online articles are returned.': 'No key needed. Two read-only routes, and only online articles are returned.':
'Aucune clé nécessaire. Deux routes en lecture seule, et seuls les articles publiés sont renvoyés.', 'Aucune clé nécessaire. Deux routes en lecture seule, et seuls les articles publiés sont renvoyés.',
@@ -961,8 +961,8 @@ export const messages = {
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.', 'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.',
'Keys start with `rcms_` and are shown in full once, when you create them.': 'Keys start with `rcms_` and are shown in full once, when you create them.':
'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.', 'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.',
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.': 'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.':
'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié.', 'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié. Les routes propriétaire et administrateur répondent 403 à une clé dont le créateur n’est ni l’un ni l’autre.',
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.': 'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.':
'Chaque clé peut faire 120 requêtes par minute. Au-delà, l’API répond 429 avec un en-tête `Retry-After`.', 'Chaque clé peut faire 120 requêtes par minute. Au-delà, l’API répond 429 avec un en-tête `Retry-After`.',
'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.': 'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.':
@@ -2741,6 +2741,96 @@ export const messages = {
'Status codes and messages': 'Codes d’état et messages', 'Status codes and messages': 'Codes d’état et messages',
'Images, CORS and ?format=': 'Images, CORS et ?format=', 'Images, CORS and ?format=': 'Images, CORS et ?format=',
'{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API', '{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API',
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.':
'Définissez votre propre type de contenu, comme des événements ou des recettes. Un propriétaire ou un administrateur crée un type composé de 1 à 50 champs ordonnés ; le type a alors ses propres routes d’entrées.',
'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.':
'Un type de contenu a une `key` (2 à 40 lettres minuscules, chiffres ou tirets, choisie à la création et jamais modifiée) et des champs. Chaque champ a une `key` (lettres, chiffres et tirets bas, commençant par une lettre), un `label`, un `type` et des règles.',
'Field object': 'Objet champ',
string: 'chaîne',
'min/max = length in characters. Can be unique.':
'min/max = longueur en caractères. Peut être unique.',
'string (Markdown)': 'chaîne (Markdown)',
'min/max = length. Cannot be unique.': 'min/max = longueur. Ne peut pas être unique.',
number: 'nombre',
'min/max = value. Can be unique.': 'min/max = valeur. Peut être unique.',
'true/false': 'true/false',
'No min/max. Cannot be unique.': 'Pas de min/max. Ne peut pas être unique.',
'string "YYYY-MM-DD"': 'chaîne « AAAA-MM-JJ »',
'min/max = earliest/latest date. Can be unique.':
'min/max = date la plus ancienne/la plus récente. Peut être unique.',
'one string from `options` (1 to 50 choices)': 'une chaîne parmi `options` (1 à 50 choix)',
'Can be unique.': 'Peut être unique.',
'the numeric id of an uploaded image (see Media)':
'l’identifiant numérique d’une image envoyée (voir Médias)',
'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many':
'identifiants d’entrées d’un autre type de contenu ; `relation: { content_type, kind }`, kind valant one_to_one | one_to_many | many_to_many',
'List all custom content types.': 'Liste tous les types de contenu personnalisés.',
'Get one content type with its fields.': 'Renvoie un type de contenu avec ses champs.',
'Create a content type.': 'Crée un type de contenu.',
"2 to 40 characters. Lowercase letters, digits and hyphens. Can't be changed later.":
'2 à 40 caractères. Lettres minuscules, chiffres et tirets. Ne peut pas être modifiée ensuite.',
'Up to 100 characters.': 'Jusqu’à 100 caractères.',
'Optional.': 'Facultatif.',
'1 to 50 fields, in order. See Field types.':
'1 à 50 champs, dans l’ordre. Voir Types de champ.',
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.":
'Remplace le nom, l’icône et les champs d’un type. La clé ne change jamais. Les clés et types des champs enregistrés ne peuvent pas changer.',
"Some existing entries don't fit the new fields. Nothing was changed.":
'Certaines entrées existantes ne conviennent pas aux nouveaux champs. Rien n’a été modifié.',
'Delete a content type. Only possible when it has no entries and no relation field points at it.':
'Supprime un type de contenu. Possible seulement s’il n’a aucune entrée et si aucun champ de relation ne pointe vers lui.',
'Read and write the content of a custom type. Any verified user can write entries.':
'Lisez et écrivez le contenu d’un type personnalisé. Tout utilisateur vérifié peut écrire des entrées.',
"An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).":
'Les `data` d’une entrée forment un objet indexé par clé de champ, validé selon le type. Les réponses ajoutent `media` (les images résolues des champs média, indexées par clé de champ : id, filename, width, height, size et thumbnail_url, ou { id, missing: true } si l’image a été supprimée) et `relations` (le premier champ résolu des entrées liées).',
'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.':
'Liste les entrées d’un type, une page à la fois. La réponse est un objet avec items, total, page et size, et non un tableau simple comme pour les articles.',
'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.':
'id, created_at ou updated_at, puis :asc ou :desc. Par exemple updated_at:desc.',
'Create an entry.': 'Crée une entrée.',
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover.":
'Les clés sont les clés de champ du type. Pour les événements : title, starts_on, capacity, online, category et cover.',
'Get one entry.': 'Renvoie une entrée.',
'Replace the data of an entry.': 'Remplace les données d’une entrée.',
'Delete an entry.': 'Supprime une entrée.',
'Built-in types': 'Types intégrés',
'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.':
'Les champs des articles, auteurs, sites, médias et utilisateurs, dans la même forme que les champs d’un type de contenu.',
'Built-in types are read-only: they cannot be changed through the API.':
'Les types intégrés sont en lecture seule : ils ne peuvent pas être modifiés par l’API.',
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.':
'Liste les types intégrés (articles, auteurs, sites, médias, utilisateurs) et leurs champs. Les clés sont article, author, site, media et user.',
'Get one built-in type. Keys are article, author, site, media and user.':
'Renvoie un type intégré. Les clés sont article, author, site, media et user.',
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library. failed_groups counts groups that could not be purged; the others were still purged.':
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une bibliothèque modifiée. failed_groups compte les groupes qui n’ont pas pu être purgés ; les autres l’ont été.',
'Sites and authors': 'Sites et auteurs',
'Read the sites and authors your content belongs to. Creating and changing them needs a session.':
'Lisez les sites et les auteurs auxquels votre contenu appartient. Les créer et les modifier demande une session.',
'Sites and authors writes': 'Écritures des sites et des auteurs',
'List variants': 'Lister les variantes',
'Create a variant': 'Créer une variante',
'Replace a variant': 'Remplacer une variante',
'Update a variant': 'Mettre à jour une variante',
'Delete a variant': 'Supprimer une variante',
'Purge the cache of a variant': 'Purger le cache d’une variante',
'Image cache status': 'État du cache d’images',
'Purge the image cache': 'Purger le cache d’images',
'List the variants of an image': 'Lister les variantes d’une image',
'Add variants to an image': 'Ajouter des variantes à une image',
'Generate one variant of an image': 'Générer une variante d’une image',
'Delete one variant of an image': 'Supprimer une variante d’une image',
'Text in code style is the literal body of the response, not a sentence.':
'Le texte en style code est le corps littéral de la réponse, pas une phrase.',
'On an entry, `invalid_entry: <reason>` is a 409 only when a unique field clashes. Any other field error is a 422.':
'Sur une entrée, `invalid_entry: <reason>` est un 409 seulement quand un champ unique entre en conflit. Toute autre erreur de champ est un 422.',
Unprocessable: 'Non traitable',
'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.':
'Une entrée ne convient pas à son type de contenu. Le corps est `invalid_entry`, puis « : » et la raison, par exemple `invalid_entry: capacity must be at most 5000`.',
'Field types': 'Types de champ',
'Value in an entry': 'Valeur dans une entrée',
'1 or higher. Default 1.': '1 ou plus. 1 par défaut.',
'One of 5, 25, 50 or 100. Default 25.': '5, 25, 50 ou 100. 25 par défaut.',
} as const } as const
export type MessageKey = keyof typeof messages export type MessageKey = keyof typeof messages
+4 -4
View File
@@ -77,14 +77,14 @@ describe('Sites and authors in the editor, articles and profile screens: message
}) })
describe('Catalogue groups of Sites and Authors in French', () => { describe('Catalogue groups of Sites and Authors in French', () => {
const groups = API_GROUPS.filter((group) => ['sites', 'authors'].includes(group.key)) const groups = API_GROUPS.filter((group) => group.key === 'sites')
const sessionGroups = SESSION_GROUPS.filter((group) => /^(Sites|Authors)/.test(group.title)) const sessionGroups = SESSION_GROUPS.filter((group) => /^Sites/.test(group.title))
it('covers the four group titles of the feature', () => { it('covers the group titles of the feature', () => {
expect([ expect([
...groups.map((group) => group.title), ...groups.map((group) => group.title),
...sessionGroups.map((group) => group.title), ...sessionGroups.map((group) => group.title),
]).toEqual(['Sites', 'Authors', 'Sites · owner only', 'Authors · owner and admin']) ]).toEqual(['Sites and authors', 'Sites and authors writes'])
}) })
it.each([ it.each([
+66 -4
View File
@@ -51,18 +51,27 @@ describe('index', () => {
'auth', 'auth',
'articles', 'articles',
'seo', 'seo',
'ctypes',
'entries',
'builtin',
'media', 'media',
'sites',
'images',
'users', 'users',
'settings', 'settings',
'sites',
'authors',
'images',
'session', 'session',
'errors', 'errors',
'notes', 'notes',
]) ])
}) })
it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => {
expect(index.endpoints).toHaveLength(85)
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42)
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37)
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
})
it('never indexes code samples or response examples', () => { it('never indexes code samples or response examples', () => {
expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([]) expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([])
expect(search('"total"').endpoints).toEqual([]) expect(search('"total"').endpoints).toEqual([])
@@ -164,12 +173,13 @@ describe('matching', () => {
it('finds sections by title, description and keywords', () => { it('finds sections by title, description and keywords', () => {
expect(routes(search('media').sections)).toEqual(['section Media']) expect(routes(search('media').sections)).toEqual(['section Media'])
expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' }) expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' })
expect(search('media').sections[0]!.entry.sectionId).toBe('media')
const login = search('login') const login = search('login')
expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login']) expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login'])
expect(routes(login.sections)).toEqual(['section Session only']) expect(routes(login.sections)).toEqual(['section Session only'])
expect(login.sections[0]!.entry).toMatchObject({ expect(login.sections[0]!.entry).toMatchObject({
locked: true, locked: true,
description: '25 routes, not available to API keys', description: '37 routes, not available to API keys',
}) })
}) })
@@ -189,6 +199,58 @@ describe('matching', () => {
}) })
}) })
describe('custom content types', () => {
it.each([
['media', 16, 1],
['404', 23, 1],
['login', 1, 1],
['entry', 7, 1],
['capacity', 2, 0],
['schema_incompatible', 1, 0],
['content type', 10, 2],
])('counts "%s" as %i endpoints and %i sections', (query, endpoints, sections) => {
const result = search(query)
expect(result.endpoints).toHaveLength(endpoints)
expect(result.sections).toHaveLength(sections)
})
it('also matches "entries" for the term entry, in text and in highlights', () => {
const keys = search('entry').endpoints.map((hit) => hit.entry.key)
expect(keys).toContain('ep:ct-delete')
expect(keys).toContain('ep:ct-update')
expect(routes(search('entry').sections)).toEqual(['section Entries'])
expect(highlightSegments('Entries and entry', ['entry'])).toEqual([
{ text: 'Entries', marked: true },
{ text: ' and ', marked: false },
{ text: 'entry', marked: true },
])
})
it('labels the events keys as body fields and a code as an error', () => {
const capacity = search('capacity').endpoints
expect(capacity.map((hit) => hit.via?.kind)).toEqual(['body', 'body'])
expect(search('starts_on').endpoints.map((hit) => hit.via)).toEqual([
{ kind: 'body', text: 'starts_on' },
{ kind: 'body', text: 'starts_on' },
])
const [schema] = search('schema_incompatible').endpoints
expect(schema!.via!.kind).toBe('error')
expect(schema!.via!.text.startsWith('409 schema_incompatible')).toBe(true)
expect(routes(search('schema_incompatible').endpoints)).toEqual([
'PUT /v1/content-type/{key}',
])
})
it('finds the new sections by their keywords', () => {
expect(routes(search('schema').sections)).toEqual([
'section Content types',
'section Built-in types',
])
expect(routes(search('author').sections)).toContain('section Sites and authors')
expect(routes(search('purge').sections)).toEqual(['section Media'])
})
})
describe('suggestions', () => { describe('suggestions', () => {
it('lists the five suggested rows in order', () => { it('lists the five suggested rows in order', () => {
expect(routes(suggestedHits(index))).toEqual([ expect(routes(suggestedHits(index))).toEqual([
+10 -2
View File
@@ -1,4 +1,4 @@
import type { HttpMethod } from '@/data/apiCatalogue' import { IMAGES_AFTER_GROUP, type ApiGroup, type HttpMethod } from '@/data/apiCatalogue'
export const SECTION_TITLE = 'text-[1.125rem]/6.5 font-semibold text-highlighted' export const SECTION_TITLE = 'text-[1.125rem]/6.5 font-semibold text-highlighted'
@@ -16,10 +16,13 @@ const METHOD_BASE =
export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted` export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted`
const PATCH_CLASS = `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`
export const METHOD_CLASS: Record<HttpMethod, string> = { export const METHOD_CLASS: Record<HttpMethod, string> = {
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`, GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`, POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
PATCH: `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`, PATCH: PATCH_CLASS,
PUT: PATCH_CLASS,
DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`, DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`,
} }
@@ -30,3 +33,8 @@ export function splitInlineCode(text: string) {
export function endpointAnchor(id: string) { export function endpointAnchor(id: string) {
return `ep-${id}` return `ep-${id}`
} }
export function groupsAroundImages(groups: ApiGroup[]) {
const split = groups.findIndex((group) => group.key === IMAGES_AFTER_GROUP) + 1
return { before: groups.slice(0, split), after: groups.slice(split) }
}
+57 -11
View File
@@ -1,10 +1,14 @@
import { t, translate } from '@/i18n' import { t, translate } from '@/i18n'
import { groupsAroundImages } from '@/lib/apiReference'
import { import {
API_GROUPS, API_GROUPS,
ERROR_ROWS, ERROR_ROWS,
LOGIN_FAMILY_CAPTION, LOGIN_FAMILY_CAPTION,
PUBLIC_AUTH_ROWS, PUBLIC_AUTH_ROWS,
SESSION_GROUPS, SESSION_GROUPS,
type ApiEndpoint,
type ApiError,
type ApiGroup,
type HttpMethod, type HttpMethod,
} from '@/data/apiCatalogue' } from '@/data/apiCatalogue'
@@ -63,6 +67,18 @@ export interface SearchIndex {
sections: SectionEntry[] sections: SectionEntry[]
} }
const ENTRY_TERM = 'entry'
const ENTRY_PLURAL = 'entries'
const GROUP_KEYWORDS: Record<string, string> = {
seo: 'seo sitemap',
ctypes: 'custom fields field types schema',
entries: 'content type custom data',
builtin: 'builtin fields schema',
media: 'purge',
sites: 'site author',
}
const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me'] const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me']
export function firstSentence(text: string) { export function firstSentence(text: string) {
@@ -74,6 +90,17 @@ export function queryTerms(query: string) {
return query.toLowerCase().split(/\s+/).filter(Boolean) return query.toLowerCase().split(/\s+/).filter(Boolean)
} }
function errorText(error: ApiError) {
const caption = translate(error.caption)
return error.tail
? `${error.status} ${caption} ${translate(error.tail)}`
: `${error.status} ${caption}`
}
function bodyKeys(endpoint: ApiEndpoint) {
return [...new Set([...endpoint.body.map((field) => field.name), ...endpoint.searchKeys])]
}
function endpointEntries(): EndpointEntry[] { function endpointEntries(): EndpointEntry[] {
const documented = API_GROUPS.flatMap((group) => const documented = API_GROUPS.flatMap((group) =>
group.endpoints.map<EndpointEntry>((endpoint) => ({ group.endpoints.map<EndpointEntry>((endpoint) => ({
@@ -91,11 +118,11 @@ function endpointEntries(): EndpointEntry[] {
kind: 'query', kind: 'query',
text: field.name, text: field.name,
})), })),
...endpoint.body.map<SearchMatch>((field) => ({ kind: 'body', text: field.name })), ...bodyKeys(endpoint).map<SearchMatch>((text) => ({ kind: 'body', text })),
], ],
errors: endpoint.errors.map<SearchMatch>((error) => ({ errors: endpoint.errors.map<SearchMatch>((error) => ({
kind: 'error', kind: 'error',
text: `${error.status} ${translate(error.caption)}`, text: errorText(error),
})), })),
})), })),
) )
@@ -154,6 +181,14 @@ function sectionEntries(): SectionEntry[] {
locked, locked,
keywords, keywords,
}) })
const { before, after } = groupsAroundImages(API_GROUPS)
const groupSection = (group: ApiGroup) =>
section(
group.key,
translate(group.title),
routeCount(group.endpoints.length),
GROUP_KEYWORDS[group.key],
)
return [ return [
section( section(
'auth', 'auth',
@@ -161,15 +196,14 @@ function sectionEntries(): SectionEntry[] {
t('Public access and API keys'), t('Public access and API keys'),
'bearer api key rcms rate limit 429 base url public', 'bearer api key rcms rate limit 429 base url public',
), ),
...API_GROUPS.map((group) => ...before.map(groupSection),
section(group.key, translate(group.title), routeCount(group.endpoints.length)),
),
section( section(
'images', 'images',
t('Images and variants'), t('Images and variants'),
t('The ?v= parameter'), t('The ?v= parameter'),
'variant image width height format quality', 'variant image width height format quality',
), ),
...after.map(groupSection),
section( section(
'session', 'session',
t('Session only'), t('Session only'),
@@ -196,19 +230,23 @@ export function buildSearchIndex(): SearchIndex {
return { endpoints: endpointEntries(), sections: sectionEntries() } return { endpoints: endpointEntries(), sections: sectionEntries() }
} }
function includesTerm(text: string, term: string) {
return text.includes(term) || (term === ENTRY_TERM && text.includes(ENTRY_PLURAL))
}
function primaryText(entry: EndpointEntry) { function primaryText(entry: EndpointEntry) {
return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase() return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase()
} }
function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined { function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined {
if (primaryText(entry).includes(term)) { if (includesTerm(primaryText(entry), term)) {
return null return null
} }
const field = entry.fields.find((item) => item.text.toLowerCase().includes(term)) const field = entry.fields.find((item) => includesTerm(item.text.toLowerCase(), term))
if (field) { if (field) {
return field return field
} }
return entry.errors.find((item) => item.text.toLowerCase().includes(term)) return entry.errors.find((item) => includesTerm(item.text.toLowerCase(), term))
} }
function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null { function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null {
@@ -225,7 +263,7 @@ function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null
function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null { function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null {
const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase() const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase()
return terms.every((term) => text.includes(term)) ? { entry, via: null } : null return terms.every((term) => includesTerm(text, term)) ? { entry, via: null } : null
} }
export function suggestedHits(index: SearchIndex): SearchHit[] { export function suggestedHits(index: SearchIndex): SearchHit[] {
@@ -254,14 +292,22 @@ function escapePattern(term: string) {
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
} }
function termPattern(term: string) {
return term === ENTRY_TERM ? 'entr(?:y|ies)' : escapePattern(term)
}
function isMarked(part: string, terms: Set<string>) {
return terms.has(part) || (part === ENTRY_PLURAL && terms.has(ENTRY_TERM))
}
export function highlightSegments(text: string, terms: string[]): HighlightSegment[] { export function highlightSegments(text: string, terms: string[]): HighlightSegment[] {
if (terms.length === 0) { if (terms.length === 0) {
return [{ text, marked: false }] return [{ text, marked: false }]
} }
const pattern = new RegExp(`(${terms.map(escapePattern).join('|')})`, 'gi') const pattern = new RegExp(`(${terms.map(termPattern).join('|')})`, 'gi')
const lowered = new Set(terms) const lowered = new Set(terms)
return text return text
.split(pattern) .split(pattern)
.filter((part) => part !== '') .filter((part) => part !== '')
.map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) })) .map((part) => ({ text: part, marked: isMarked(part.toLowerCase(), lowered) }))
} }
+181 -47
View File
@@ -22,12 +22,15 @@ import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import { import {
API_GROUPS, API_GROUPS,
ERROR_ROWS, ERROR_ROWS,
FIELD_TYPES_ANCHOR,
ERROR_SAMPLE, ERROR_SAMPLE,
PUBLIC_AUTH_ROWS, PUBLIC_AUTH_ROWS,
SESSION_GROUPS, SESSION_GROUPS,
authExamples, authExamples,
type ApiGroup,
type ErrorRow,
} from '@/data/apiCatalogue' } from '@/data/apiCatalogue'
import { SECTION_TITLE, endpointAnchor } from '@/lib/apiReference' import { SECTION_TITLE, endpointAnchor, groupsAroundImages } from '@/lib/apiReference'
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch' import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
const SPY_OFFSET_PX = 120 const SPY_OFFSET_PX = 120
@@ -45,13 +48,17 @@ const errorSampleTabs = computed(() => [
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0) const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
function groupSection(group: ApiGroup) {
return { id: group.key, label: translate(group.title) }
}
const sections = computed(() => [ const sections = computed(() => [
{ id: 'auth', label: t('Authentication') }, { id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ ...groupsBeforeImages.map(groupSection),
id: group.key,
label: translate(group.title),
})),
{ id: 'images', label: t('Images and variants') }, { id: 'images', label: t('Images and variants') },
...groupsAfterImages.map(groupSection),
{ id: 'session', label: t('Session only') }, { id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') }, { id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') }, { id: 'notes', label: t('Notes') },
@@ -67,7 +74,7 @@ const authItems = computed(() => {
const authNotes = computed(() => [ const authNotes = computed(() => [
t( t(
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.', 'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.',
), ),
t( t(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.', 'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
@@ -110,8 +117,64 @@ const errorColumns = computed(() => [
}, },
]) ])
function quoteMessages(messages: string[]) { interface MessagePart {
return messages.map((message) => `"${message}"`).join(' / ') text: string
code: boolean
}
function messageParts(row: ErrorRow) {
const parts: MessagePart[] = []
const push = (text: string, code: boolean) => {
if (parts.length > 0) {
parts.push({ text: ' / ', code: false })
}
parts.push({ text, code })
}
row.messages.forEach((message) => push(`"${message}"`, false))
row.codes?.forEach((code) => push(code, true))
return parts
}
const fieldTypeColumns = computed(() => [
{
accessorKey: 'type',
get header() {
return t('Type')
},
meta: { class: { th: 'w-22', td: 'w-22' } },
},
{
accessorKey: 'value',
get header() {
return t('Value in an entry')
},
meta: { class: { th: 'lg:w-1/2', td: 'lg:w-1/2' } },
},
{
accessorKey: 'rules',
get header() {
return t('Rules')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
const pageBlocks = [
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
{ kind: 'images' as const, key: 'images', group: null },
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
]
function snippetTabs(group: ApiGroup) {
return group.snippet
? [
{
k: group.snippet.key,
label: translate(group.snippet.label),
code: group.snippet.code,
},
]
: []
} }
function routeCount(count: number) { function routeCount(count: number) {
@@ -371,7 +434,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
<p class="max-w-180 text-base text-pretty text-toned"> <p class="max-w-180 text-base text-pretty text-toned">
{{ {{
t( t(
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.', 'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
) )
}} }}
</p> </p>
@@ -458,33 +521,88 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</div> </div>
</section> </section>
<section <template v-for="block in pageBlocks" :key="block.key">
v-for="group in API_GROUPS" <IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
:key="group.key" <section
:data-anchor="group.key" v-else
:aria-labelledby="`h-${group.key}`" :data-anchor="block.group.key"
:class="SECTION_CLASS" :aria-labelledby="`h-${block.group.key}`"
> :class="SECTION_CLASS"
<div class="flex flex-wrap items-baseline gap-2.5"> >
<h2 :id="`h-${group.key}`" :class="SECTION_TITLE"> <div class="flex flex-wrap items-baseline gap-2.5">
{{ translate(group.title) }} <h2 :id="`h-${block.group.key}`" :class="SECTION_TITLE">
</h2> {{ translate(block.group.title) }}
<span class="text-muted">{{ routeCount(group.endpoints.length) }}</span> </h2>
</div> <span class="text-muted">{{
<span class="text-pretty text-toned">{{ translate(group.description) }}</span> routeCount(block.group.endpoints.length)
<div class="border border-t-0 border-default bg-default"> }}</span>
<EndpointCard </div>
v-for="endpoint in group.endpoints" <span class="text-pretty text-toned">{{
:key="endpoint.id" translate(block.group.description)
:ref="(card) => setCard(endpoint.id, card)" }}</span>
:endpoint="endpoint" <div v-if="block.group.note" class="flex gap-2.5 text-base text-toned">
:origin="origin" <UIcon name="i-lucide-info" :class="NOTE_ICON" />
:flash="flashedCard === endpoint.id ? flashMode : null" <span class="text-pretty"
/> ><CodeText :text="translate(block.group.note)"
</div> /></span>
</section> </div>
<div
<IntegrationImages :origin="origin" /> v-if="block.group.fieldTypes"
:data-anchor="FIELD_TYPES_ANCHOR"
class="flex scroll-mt-16 flex-col gap-1.5 lg:scroll-mt-8"
>
<span class="font-semibold text-highlighted">{{
t('Field types')
}}</span>
<UTable
:data="block.group.fieldTypes"
:columns="fieldTypeColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-2.5 py-1.5 text-sm font-medium text-muted',
td: 'px-2.5 py-2 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #type-cell="{ row }">
<span class="font-mono text-xs font-medium text-highlighted">{{
row.original.type
}}</span>
</template>
<template #value-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty"
><CodeText :text="translate(row.original.value)"
/></span>
<span class="text-pretty lg:hidden"
><CodeText :text="translate(row.original.rules)"
/></span>
</div>
</template>
<template #rules-cell="{ row }">
<span class="text-pretty"
><CodeText :text="translate(row.original.rules)"
/></span>
</template>
</UTable>
</div>
<CodeSnippet v-if="block.group.snippet" :tabs="snippetTabs(block.group)" />
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in block.group.endpoints"
:key="endpoint.id"
:ref="(card) => setCard(endpoint.id, card)"
:endpoint="endpoint"
:origin="origin"
:flash="flashedCard === endpoint.id ? flashMode : null"
/>
</div>
</section>
</template>
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS"> <section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5"> <div class="flex flex-wrap items-baseline gap-2.5">
@@ -492,7 +610,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
{{ t('Session only') }} {{ t('Session only') }}
</h2> </h2>
<span class="text-muted">{{ <span class="text-muted">{{
t('{0} routes, made for a browser session', [sessionRouteCount]) t('{0} routes, not available to API keys', [sessionRouteCount])
}}</span> }}</span>
</div> </div>
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" /> <SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
@@ -532,29 +650,45 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
translate(row.original.meaning) translate(row.original.meaning)
}}</span> }}</span>
<div class="flex flex-col gap-0.5 lg:hidden"> <div class="flex flex-col gap-0.5 lg:hidden">
<span class="text-pretty text-toned">{{ <span class="text-pretty text-toned"
quoteMessages(row.original.messages) ><template
}}</span> v-for="(part, index) in messageParts(row.original)"
:key="index"
><code
v-if="part.code"
class="font-mono text-xs text-highlighted"
>{{ part.text }}</code
><template v-else>{{ part.text }}</template></template
></span
>
<span <span
v-if="row.original.note" v-if="row.original.note"
class="text-pretty text-muted" class="text-pretty text-muted"
data-testid="error-note" data-testid="error-note"
>{{ translate(row.original.note) }}</span ><CodeText :text="translate(row.original.note)"
> /></span>
</div> </div>
</div> </div>
</template> </template>
<template #messages-cell="{ row }"> <template #messages-cell="{ row }">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<span class="text-pretty">{{ <span class="text-pretty"
quoteMessages(row.original.messages) ><template
}}</span> v-for="(part, index) in messageParts(row.original)"
:key="index"
><code
v-if="part.code"
class="font-mono text-xs text-highlighted"
>{{ part.text }}</code
><template v-else>{{ part.text }}</template></template
></span
>
<span <span
v-if="row.original.note" v-if="row.original.note"
class="text-pretty text-muted" class="text-pretty text-muted"
data-testid="error-note" data-testid="error-note"
>{{ translate(row.original.note) }}</span ><CodeText :text="translate(row.original.note)"
> /></span>
</div> </div>
</template> </template>
</UTable> </UTable>
@@ -19,7 +19,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
const ORIGIN = 'https://cms.renew.example' const ORIGIN = 'https://cms.renew.example'
const LIST_PATH = '/v1/image-variants' const LIST_PATH = '/v1/image-variants'
const BASE = `${ORIGIN}/media/north-wall.jpg` const BASE = `${ORIGIN}/media/north-wall.jpg`
const SECTION_ORDER = ['settings', 'images', 'session', 'errors'] const SECTION_ORDER = ['images', 'settings', 'session', 'errors']
const BODY_UNKNOWN = const BODY_UNKNOWN =
'{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }' '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'
const BODY_UNSUPPORTED = const BODY_UNSUPPORTED =
@@ -269,7 +269,7 @@ describe('I1. Many variants', () => {
const labels = Array.from( const labels = Array.from(
document.body.querySelectorAll('nav[aria-label="On this page"] button'), document.body.querySelectorAll('nav[aria-label="On this page"] button'),
).map((item) => item.textContent?.trim()) ).map((item) => item.textContent?.trim())
expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Session only') - 1) expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Sites and authors') + 1)
const request = requestSnippet() const request = requestSnippet()
expect(tabs(request).map((tab) => tab.textContent?.trim())).toEqual([ expect(tabs(request).map((tab) => tab.textContent?.trim())).toEqual([
@@ -344,13 +344,20 @@ describe('8c Typing media', () => {
expect(groupTitles()).toEqual(['Endpoints', 'Sections']) expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
const rows = optionRoutes() const rows = optionRoutes()
expect(rows.slice(0, 6).every((route) => route!.includes('/v1/media'))).toBe(true) expect(rows.slice(0, 6)).toEqual([
'GET /v1/builtin-type/',
'GET /v1/media/',
'GET /v1/media/{id}',
'POST /v1/media/',
'PATCH /v1/media/{id}',
'DELETE /v1/media/{id}',
])
expect(rows.at(-1)).toBe('Section Media') expect(rows.at(-1)).toBe('Section Media')
expect(options().at(-1)!.textContent).toContain('11 routes') expect(options().at(-1)!.textContent).toContain('11 routes')
expect(document.body.textContent).toContain('12 results') expect(document.body.textContent).toContain('17 results')
expect(liveRegion()).toBe('12 results, showing 7') expect(liveRegion()).toBe('17 results, showing 7')
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
'5 more endpoints. Keep typing to narrow the list.', '10 more endpoints. Keep typing to narrow the list.',
) )
const marks = Array.from(document.body.querySelectorAll('[role="option"] mark')) const marks = Array.from(document.body.querySelectorAll('[role="option"] mark'))
expect(marks.length).toBeGreaterThan(0) expect(marks.length).toBeGreaterThan(0)
@@ -374,12 +381,11 @@ describe('8d More endpoints than the cap', () => {
expect(options().at(-1)!.getAttribute('aria-label')).toBe( expect(options().at(-1)!.getAttribute('aria-label')).toBe(
'Section Errors. Status codes and messages', 'Section Errors. Status codes and messages',
) )
const total = Number(/(\d+) results/.exec(document.body.textContent!)![1]) expect(document.body.textContent).toContain('24 results')
expect(total).toBeGreaterThan(7)
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
`${total - 7} more endpoints. Keep typing to narrow the list.`, '17 more endpoints. Keep typing to narrow the list.',
) )
expect(liveRegion()).toBe(`${total} results, showing 7`) expect(liveRegion()).toBe('24 results, showing 7')
}) })
}) })
@@ -434,7 +440,7 @@ describe('8f Session-only route', () => {
expect(path.classList.contains('font-normal')).toBe(true) expect(path.classList.contains('font-normal')).toBe(true)
expect(route!.innerHTML).toContain('border-dashed') expect(route!.innerHTML).toContain('border-dashed')
expect(section!.getAttribute('aria-label')).toBe( expect(section!.getAttribute('aria-label')).toBe(
'Section Session only. 25 routes, not available to API keys', 'Section Session only. 37 routes, not available to API keys',
) )
expect(section!.querySelector('svg')).not.toBeNull() expect(section!.querySelector('svg')).not.toBeNull()
expect(document.body.textContent).toContain('2 results') expect(document.body.textContent).toContain('2 results')
@@ -551,6 +557,91 @@ describe('Roles and live region', () => {
}) })
}) })
describe('9f to 9i Content types and entries', () => {
async function searchFor(query: string) {
const wrapper = await mountIntegration()
await openWithSlash()
await type(query)
return wrapper
}
it('9f lists the entry routes, matches "entries" and caps with the plural line', async () => {
await searchFor('entry')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
expect(optionRoutes()).toEqual([
'GET /v1/content-type/{key}/entry',
'POST /v1/content-type/{key}/entry',
'GET /v1/content-type/{key}/entry/{id}',
'PUT /v1/content-type/{key}/entry/{id}',
'DELETE /v1/content-type/{key}/entry/{id}',
'PUT /v1/content-type/{key}',
'Section Entries',
])
expect(document.body.textContent).toContain('8 results')
expect(liveRegion()).toBe('8 results, showing 7')
expect(document.body.textContent).toContain(
'1 more endpoint. Keep typing to narrow the list.',
)
expect(document.body.textContent).not.toContain('1 more endpoints')
})
it('9g labels the capacity key as a Body field on POST and PUT entry', async () => {
await searchFor('capacity')
expect(optionRoutes()).toEqual([
'POST /v1/content-type/{key}/entry',
'PUT /v1/content-type/{key}/entry/{id}',
])
for (const row of options()) {
expect(row.textContent).toContain('Body field')
expect(row.querySelector('mark')!.textContent).toBe('capacity')
}
expect(document.body.textContent).toContain('2 results')
expect(liveRegion()).toBe('2 results')
})
it('9h labels schema_incompatible as an Error on PUT /v1/content-type/{key}', async () => {
await searchFor('schema_incompatible')
expect(optionRoutes()).toEqual(['PUT /v1/content-type/{key}'])
expect(options()[0]!.textContent).toContain('Error')
expect(options()[0]!.textContent).toContain('409 schema_incompatible')
expect(liveRegion()).toBe('1 result')
})
it('9i lists Content types and Entries in the Sections group', async () => {
await searchFor('content type')
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
expect(optionRoutes().slice(-2)).toEqual(['Section Content types', 'Section Entries'])
expect(options()).toHaveLength(8)
expect(document.body.textContent).toContain('12 results')
expect(liveRegion()).toBe('12 results, showing 8')
})
it('selects PUT /v1/content-type/{key}, expands it, flashes it and reads Content types', async () => {
const wrapper = await searchFor('schema_incompatible')
await pressInPalette('Enter')
await wrapper.vm.$nextTick()
await settle()
const button = cardFor(wrapper, 'ct-update').get('button[aria-expanded]')
expect(paletteOpen()).toBe(false)
expect(button.attributes('aria-expanded')).toBe('true')
expect(document.activeElement).toBe(button.element)
expect(button.classes()).toContain('endpoint-flash')
expect(window.location.hash).toBe('#ep-ct-update')
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.get('button[aria-current="true"]').text()).toBe('Content types')
await vi.advanceTimersByTimeAsync(FLASH_MS)
await flushPromises()
expect(button.classes()).not.toContain('endpoint-flash')
})
})
describe('8h Selecting an endpoint', () => { describe('8h Selecting an endpoint', () => {
async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') { async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') {
await openWithSlash() await openWithSlash()
+562 -26
View File
@@ -20,12 +20,14 @@ const SECTION_LABELS = [
'Authentication', 'Authentication',
'Articles', 'Articles',
'Search and sharing', 'Search and sharing',
'Content types',
'Entries',
'Built-in types',
'Media', 'Media',
'Sites and authors',
'Images and variants',
'Users and invites', 'Users and invites',
'Settings', 'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only', 'Session only',
'Errors', 'Errors',
'Notes', 'Notes',
@@ -47,7 +49,7 @@ function stubClipboard() {
return writeText return writeText
} }
async function mountIntegration() { async function mountIntegration(host?: HTMLElement) {
stubOrigin(STUB_ORIGIN) stubOrigin(STUB_ORIGIN)
fetchSpy = vi.fn() fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy) vi.stubGlobal('fetch', fetchSpy)
@@ -57,7 +59,7 @@ async function mountIntegration() {
session.status = 'ready' session.status = 'ready'
await router.push('/integration') await router.push('/integration')
const wrapper = mount(IntegrationView, { const wrapper = mount(IntegrationView, {
attachTo: document.body, attachTo: host ?? document.body,
global: { global: {
plugins: [router, uiPlugin], plugins: [router, uiPlugin],
}, },
@@ -130,7 +132,7 @@ describe('Default, every card collapsed', () => {
expect(wrapper.findAll('h1').map((heading) => heading.text())).toContain('API reference') expect(wrapper.findAll('h1').map((heading) => heading.text())).toContain('API reference')
expect(wrapper.text()).toContain('Everything a developer needs to build on RenewCMS.') expect(wrapper.text()).toContain('Everything a developer needs to build on RenewCMS.')
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.', 'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
) )
expect(wrapper.text()).toContain(STUB_ORIGIN) expect(wrapper.text()).toContain(STUB_ORIGIN)
expect(findButton(wrapper, 'Copy')).toBeTruthy() expect(findButton(wrapper, 'Copy')).toBeTruthy()
@@ -149,6 +151,7 @@ describe('Default, every card collapsed', () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]') const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button')).toHaveLength(14)
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS) expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
const jump = wrapper.get('button[aria-label="Jump to section"]') const jump = wrapper.get('button[aria-label="Jump to section"]')
expect(jump.text()).toContain('Jump to') expect(jump.text()).toContain('Jump to')
@@ -171,18 +174,150 @@ describe('Default, every card collapsed', () => {
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST') expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
}) })
it('groups the cards as 8, 2, 10, 6 and 1', async () => { it.each([
['articles', 'Articles', '8 routes', 8],
['seo', 'Search and sharing', '2 routes', 2],
['ctypes', 'Content types', '5 routes', 5],
['entries', 'Entries', '5 routes', 5],
['builtin', 'Built-in types', '2 routes', 2],
['media', 'Media', '11 routes', 11],
['sites', 'Sites and authors', '2 routes', 2],
['users', 'Users and invites', '6 routes', 6],
['settings', 'Settings', '1 route', 1],
])('titles the %s group %s with %s', async (id, title, count, cards) => {
const wrapper = await mountIntegration()
const group = section(wrapper, id)
expect(group.get('h2').text()).toBe(title)
expect(group.text()).toContain(count)
expect(group.findAllComponents(EndpointCard)).toHaveLength(cards)
group.findAllComponents(EndpointCard).forEach((item) => {
expect(item.text()).toContain('API key OK')
})
})
it('lists the groups on the page in the order of the index', async () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const ids = wrapper
.findAll('section[data-anchor]')
.map((item) => item.attributes('data-anchor'))
expect(ids).toEqual([
'auth',
'articles',
'seo',
'ctypes',
'entries',
'builtin',
'media',
'sites',
'images',
'users',
'settings',
'session',
'errors',
'notes',
])
})
it.each([
['GET /v1/content-type/', 'Verified user'],
['GET /v1/content-type/{key}', 'Verified user'],
['POST /v1/content-type/', 'Owner or admin'],
['PUT /v1/content-type/{key}', 'Owner or admin'],
['DELETE /v1/content-type/{key}', 'Owner or admin'],
['GET /v1/content-type/{key}/entry', 'Verified user'],
['POST /v1/content-type/{key}/entry', 'Verified user'],
['GET /v1/content-type/{key}/entry/{id}', 'Verified user'],
['PUT /v1/content-type/{key}/entry/{id}', 'Verified user'],
['DELETE /v1/content-type/{key}/entry/{id}', 'Verified user'],
['GET /v1/builtin-type/', 'Owner or admin'],
['GET /v1/builtin-type/{key}', 'Owner or admin'],
['GET /v1/media/purge-settings', 'Owner or admin'],
['PATCH /v1/media/purge-settings', 'Owner or admin'],
['GET /v1/media/purge-preview', 'Owner or admin'],
['POST /v1/media/purge', 'Owner or admin'],
['GET /v1/site/', 'Verified user'],
['GET /v1/author/', 'Verified user'],
])('shows %s with the chip %s and API key OK', async (route, access) => {
const wrapper = await mountIntegration()
const text = row(wrapper, route).text()
expect(text).toContain(route.split(' ')[0]!)
expect(text).toContain(access)
expect(text).toContain('API key OK')
})
it('reads the descriptions of the Content types, Entries and Built-in types groups', async () => {
const wrapper = await mountIntegration()
expect(section(wrapper, 'ctypes').text()).toContain(
'Define your own kind of content, like events or recipes. An owner or admin creates a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
)
expect(section(wrapper, 'entries').text()).toContain(
'Read and write the content of a custom type. Any verified user can write entries.',
)
const builtin = section(wrapper, 'builtin').text()
expect(builtin).toContain('Built-in types are read-only')
expect(builtin).toContain('Keys are article, author, site, media and user.')
})
it('puts the ct-fields anchor above the first Content types route, outside the index', async () => {
const wrapper = await mountIntegration()
const group = section(wrapper, 'ctypes')
const anchor = group.get('[data-anchor="ct-fields"]')
const firstRoute = group.get('[data-anchor="ep-ct-list"]')
expect( expect(
['articles', 'seo', 'media', 'users', 'settings'].map( anchor.element.compareDocumentPosition(firstRoute.element) &
(id) => section(wrapper, id).findAllComponents(EndpointCard).length, Node.DOCUMENT_POSITION_FOLLOWING,
), ).toBeTruthy()
).toEqual([8, 2, 11, 6, 1]) const index = wrapper.get('nav[aria-label="On this page"]')
expect(section(wrapper, 'articles').text()).toContain('8 routes') expect(index.text()).not.toContain('Field types')
expect(section(wrapper, 'seo').text()).toContain('2 routes') expect(anchor.text()).toContain('Field types')
expect(section(wrapper, 'sites').text()).toContain('1 route') const headers = anchor.findAll('th').map((item) => item.text())
expect(section(wrapper, 'settings').text()).toContain('1 route') expect(headers).toEqual(['Type', 'Value in an entry', 'Rules'])
expect(anchor.findAll('tbody tr').map((item) => item.find('td').text())).toEqual([
'text',
'richtext',
'number',
'boolean',
'date',
'select',
'media',
'relation',
])
})
it('shows the sample field object above the routes', async () => {
const wrapper = await mountIntegration()
const group = section(wrapper, 'ctypes')
expect(group.get('[role="tablist"]').text()).toBe('Field object')
const sample = JSON.parse(group.get('pre').text())
expect(sample).toEqual({
key: 'capacity',
label: 'Capacity',
type: 'number',
required: true,
unique: false,
default: null,
min: 1,
max: 5000,
options: null,
relation: null,
})
})
it('draws the PUT badge with the PATCH colours', async () => {
const wrapper = await mountIntegration()
const classesOf = (route: string) =>
row(wrapper, route)
.get('span.font-mono')
.element.parentElement!.querySelector('[class*="h-5.5"]')!.className
expect(classesOf('PUT /v1/content-type/{key}')).toBe(classesOf('PATCH /v1/media/{id}'))
expect(classesOf('PUT /v1/content-type/{key}')).toContain('text-warning')
}) })
it('makes no /v1 request for a session without the owner or admin role', async () => { it('makes no /v1 request for a session without the owner or admin role', async () => {
@@ -370,16 +505,17 @@ describe('Authentication', () => {
}) })
describe('Session only', () => { describe('Session only', () => {
it('renders the banner, 25 rows without expand controls and the 403 chips', async () => { it('renders the banner, 37 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const session = section(wrapper, 'session') const session = section(wrapper, 'session')
expect(session.text()).toContain('25 routes') expect(session.get('h2').text()).toBe('Session only')
expect(session.text()).toContain('37 routes, not available to API keys')
expect(session.text()).toContain( expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."', 'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
) )
const rows = session.findAll('[data-session-row]') const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(25) expect(rows).toHaveLength(37)
expect(session.findAll('[data-session-row] button')).toHaveLength(0) expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0) expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
@@ -389,8 +525,12 @@ describe('Session only', () => {
'POST /v1/system/update/check', 'POST /v1/system/update/check',
'POST /v1/system/update/download', 'POST /v1/system/update/download',
'POST /v1/system/update/restart', 'POST /v1/system/update/restart',
'PATCH /v1/settings',
'POST /v1/auth/verify', 'POST /v1/auth/verify',
'DELETE /v1/auth/register', 'DELETE /v1/auth/register',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
'POST /v1/site/', 'POST /v1/site/',
'PATCH /v1/site/{id}', 'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default', 'PATCH /v1/site/{id}/default',
@@ -399,13 +539,21 @@ describe('Session only', () => {
'PATCH /v1/author/{id}', 'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}', 'DELETE /v1/author/{id}',
'POST /v1/author/me', 'POST /v1/author/me',
'PATCH /v1/settings', 'GET /v1/image-variants/',
'POST /v1/image-variants/',
'PUT /v1/image-variants/{name}',
'PATCH /v1/image-variants/{name}',
'DELETE /v1/image-variants/{name}',
'POST /v1/image-variants/{name}/purge',
'GET /v1/image-variants/cache',
'POST /v1/image-variants/cache/purge',
'GET /v1/media/{id}/variants',
'POST /v1/media/{id}/variants',
'POST /v1/media/{id}/variants/{name}',
'DELETE /v1/media/{id}/variants/{name}',
'GET /v1/activity', 'GET /v1/activity',
'GET /v1/activity/summary', 'GET /v1/activity/summary',
'GET /v1/activity/export', 'GET /v1/activity/export',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
]) ])
chipRows.forEach((item) => chipRows.forEach((item) =>
expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true), expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true),
@@ -432,13 +580,24 @@ describe('Session only', () => {
} }
}) })
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', async () => { it('lists the six sub-groups in order and every /v1/system route in System', async () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const session = section(wrapper, 'session') const session = section(wrapper, 'session')
expect(session.text()).toContain('System · owner only') const titles = [
expect(session.text()).toContain('Sign in and account') 'System · owner only',
expect(session.text()).toContain('API key management') 'Sign in and account',
'API key management',
'Sites and authors writes',
'Image variants',
'Activity',
]
const text = session.text()
const positions = titles.map((title) => text.indexOf(title))
expect(positions.every((position) => position > -1)).toBe(true)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
const systemGroup = session.findAll('[data-session-row]').slice(0, 5).map(rowRoute)
expect(systemGroup.filter((route) => route.includes('/v1/system'))).toHaveLength(4)
const rowLabels = wrapper const rowLabels = wrapper
.findAll('button[aria-expanded]') .findAll('button[aria-expanded]')
.map((button) => button.attributes('aria-label')) .map((button) => button.attributes('aria-label'))
@@ -483,6 +642,7 @@ describe('Errors', () => {
'403', '403',
'404', '404',
'409', '409',
'422',
'429', '429',
'500', '500',
]) ])
@@ -492,6 +652,7 @@ describe('Errors', () => {
'The expiry must be a date in the future.', 'The expiry must be a date in the future.',
'You must be logged in to access this resource.', 'You must be logged in to access this resource.',
'This action needs a real session.', 'This action needs a real session.',
'Only owners and admins can do this.',
'Only the owner can do this.', 'Only the owner can do this.',
'You must verify your account to access this resource.', 'You must verify your account to access this resource.',
'Article not found', 'Article not found',
@@ -500,6 +661,8 @@ describe('Errors', () => {
'API key not found', 'API key not found',
"There's already a pending invite for this address.", "There's already a pending invite for this address.",
'This invite has already been accepted.', 'This invite has already been accepted.',
'Some of the images to merge changed. Refresh and try again.',
'This address already has an account.',
'Too many requests. Please try again later.', 'Too many requests. Please try again later.',
'Internal server error', 'Internal server error',
]) { ]) {
@@ -516,6 +679,69 @@ describe('Errors', () => {
}) })
}) })
describe('Errors table rows of the custom content types round', () => {
function rowOf(wrapper: VueWrapper, status: string) {
return section(wrapper, 'errors')
.findAll('tbody tr')
.find((item) => item.find('td').text() === status)!
}
function codes(item: ReturnType<VueWrapper['get']>) {
return item.findAll('td:nth-child(3) code').map((code) => code.text())
}
it('lists the four 403 texts in order and keeps the SEO defaults note', async () => {
const wrapper = await mountIntegration()
const forbidden = rowOf(wrapper, '403')
const text = forbidden.find('td:nth-child(3)').text()
const order = [
'This action needs a real session.',
'Only owners and admins can do this.',
'Only the owner can do this.',
'You must verify your account to access this resource.',
].map((message) => text.indexOf(message))
expect(order.every((position) => position > -1)).toBe(true)
expect(order).toEqual([...order].sort((a, b) => a - b))
expect(forbidden.text()).toContain(
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
)
})
it('adds the new codes to the 404, 409 and 422 rows and explains code style', async () => {
const wrapper = await mountIntegration()
expect(codes(rowOf(wrapper, '404'))).toEqual([
'content_type_not_found',
'entry_not_found',
'builtin_type_not_found',
])
expect(rowOf(wrapper, '404').text()).toContain(
'Text in code style is the literal body of the response, not a sentence.',
)
expect(codes(rowOf(wrapper, '409'))).toEqual([
'content_type_key_taken',
'content_type_has_entries',
'content_type_referenced',
'schema_incompatible',
'invalid_entry: <reason>',
'slug_taken',
'invalid_entry: <reason>',
])
expect(rowOf(wrapper, '409').text()).toContain('only when a unique field clashes')
expect(codes(rowOf(wrapper, '422'))).toContain('invalid_entry: <reason>')
expect(rowOf(wrapper, '422').text()).toContain(
'invalid_entry: capacity must be at most 5000',
)
})
it('no longer says there is no 409 or 422 row', async () => {
const wrapper = await mountIntegration()
expect(section(wrapper, 'errors').text()).not.toMatch(/no 409|409 or 422 row|no .*422/i)
})
})
describe('Navigation', () => { describe('Navigation', () => {
it('scrolls to the anchor and marks the index item current', async () => { it('scrolls to the anchor and marks the index item current', async () => {
const scrollIntoView = vi.fn() const scrollIntoView = vi.fn()
@@ -673,3 +899,313 @@ describe('CodeSnippet migration', () => {
expect(importers).toEqual([]) expect(importers).toEqual([])
}) })
}) })
describe('Expanded cards of the custom content types round', () => {
const MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const ID_FORMAT =
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
it.each([
[
'POST /v1/content-type/',
['key', 'name', 'icon', 'fields'],
['400', '401', 'Only owners and admins can do this.', 'content_type_key_taken'],
],
[
'PUT /v1/content-type/{key}',
['name', 'icon', 'fields'],
['400', '401', '403', 'content_type_not_found', 'schema_incompatible'],
],
[
'DELETE /v1/content-type/{key}',
[],
[
'401',
'403',
'content_type_not_found',
'content_type_has_entries',
'content_type_referenced',
],
],
[
'GET /v1/content-type/{key}/entry',
['page', 'size', 'sort'],
['Invalid page, size or sort.', '401', 'content_type_not_found'],
],
[
'POST /v1/content-type/{key}/entry',
['data'],
[
'400',
'401',
'content_type_not_found',
'invalid_entry: title must be unique',
'invalid_entry: capacity must be at most 5000',
],
],
[
'GET /v1/content-type/{key}/entry/{id}',
[],
[ID_FORMAT, '401', 'entry_not_found', 'content_type_not_found'],
],
[
'PUT /v1/content-type/{key}/entry/{id}',
['data'],
[
'400',
'401',
'entry_not_found',
'content_type_not_found',
'invalid_entry: title must be unique',
'invalid_entry: capacity must be at most 5000',
],
],
['GET /v1/builtin-type/{key}', [], ['401', '403', 'builtin_type_not_found']],
])('%s shows its fields and its errors', async (route, fields, errors) => {
const wrapper = await mountIntegration()
const opened = await expand(wrapper, route)
const text = opened.text()
if (fields.length === 0) {
expect(text).toContain('No request body.')
}
for (const name of fields) {
expect(opened.findAll('td').map((cell) => cell.text())).toContain(name)
}
for (const error of errors) {
expect(text).toContain(error)
}
const literalCodes = opened.findAll('[title] code').map((code) => code.text())
for (const error of errors.filter((item) => /^[a-z_]+(: .*)?$/.test(item))) {
expect(literalCodes).toContain(error)
}
})
it('shows the 400 sentence and the schema_incompatible tail as captions', async () => {
const wrapper = await mountIntegration()
const update = await expand(wrapper, 'PUT /v1/content-type/{key}')
const create = await expand(wrapper, 'POST /v1/content-type/')
expect(update.text()).toContain(
"Some existing entries don't fit the new fields. Nothing was changed.",
)
expect(create.text()).toContain(MISMATCH)
})
it('explains the request table of the entry data', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/content-type/{key}/entry')
expect(created.text()).toContain(
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover.",
)
})
})
describe('Response samples follow the real API', () => {
async function response(route: string) {
const wrapper = await mountIntegration()
const opened = await expand(wrapper, route)
await activateTab(opened.element as HTMLElement, 'Response')
return { opened, body: opened.get('pre').text() }
}
it('answers the entries list with items, total, page and size', async () => {
const { body } = await response('GET /v1/content-type/{key}/entry')
expect(Object.keys(JSON.parse(body))).toEqual(['items', 'total', 'page', 'size'])
})
it('gives an entry its keys and a media object keyed by field key', async () => {
const { body } = await response('GET /v1/content-type/{key}/entry/{id}')
const entry = JSON.parse(body)
expect(Object.keys(entry)).toEqual([
'id',
'content_type',
'data',
'created_by',
'updated_by',
'created_at',
'updated_at',
'media',
'relations',
])
expect(Object.keys(entry.media)).toEqual(['cover'])
})
it('gives a built-in type only key, name and fields', async () => {
const { body } = await response('GET /v1/builtin-type/{key}')
expect(Object.keys(JSON.parse(body))).toEqual(['key', 'name', 'fields'])
expect(body).not.toContain('"icon"')
})
it('gives the purge exactly the seven real keys and says what failed_groups counts', async () => {
const { opened, body } = await response('POST /v1/media/purge')
expect(Object.keys(JSON.parse(body))).toEqual([
'groups_total',
'groups_purged',
'merged',
'articles_updated',
'sites_updated',
'authors_updated',
'failed_groups',
])
expect(body).not.toContain('articlesUpdated')
expect(body).not.toContain('"count"')
expect(opened.text()).toContain('fingerprint')
expect(opened.text()).toContain(
'failed_groups counts groups that could not be purged; the others were still purged.',
)
})
it('names the purge settings keys', async () => {
const { body } = await response('GET /v1/media/purge-settings')
for (const key of [
'auto_purge_enabled',
'auto_purge_interval_days',
'last_purge_at',
'last_purge_merged',
]) {
expect(body).toContain(`"${key}"`)
}
const patch = await expand(await mountIntegration(), 'PATCH /v1/media/purge-settings')
expect(patch.text()).toContain('1 to 365')
})
it.each(['POST /v1/content-type/', 'POST /v1/content-type/{key}/entry'])(
'shows %s as the JSON body with no status line',
async (route) => {
const { body } = await response(route)
expect(body.startsWith('{')).toBe(true)
expect(body).not.toMatch(/^HTTP\/|^201/)
},
)
})
describe('Paged lists', () => {
it('lists page and size on GET /v1/media/ with the 400 on a bad query', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
expect(list.text()).toContain('Query parameters')
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
expect(list.text()).toContain('The media query is invalid.')
})
it('lists page and size on GET /v1/author/ and answers an object', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/author/')
await activateTab(list.element as HTMLElement, 'Response')
const cells = list.findAll('td').map((cell) => cell.text())
expect(cells).toContain('page')
expect(cells).toContain('size')
expect(Object.keys(JSON.parse(list.get('pre').text()))).toEqual([
'items',
'total',
'page',
'size',
])
})
it('opens GET /v1/site/ with its sites', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/site/')
expect(list.text()).toContain('Verified user')
expect(list.text()).toContain('Query parameters')
})
})
describe('Authentication copy of the custom content types round', () => {
it('says a key manages content types and entries and what happens to a lesser creator', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain(
'API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
)
const auth = section(wrapper, 'auth').text()
expect(auth).toContain(
"Requests act as the user who created the key, with that user's role (owner or admin).",
)
expect(auth).toContain(
'Owner and admin routes answer 403 to a key whose creator is neither.',
)
})
})
describe('9j Navigation, scroll-spy on Content types', () => {
function scrollerHost() {
const host = document.createElement('div')
host.style.overflowY = 'auto'
document.body.appendChild(host)
return host
}
function stubOffsets(host: HTMLElement) {
const order = ['auth', 'articles', 'seo', 'ctypes']
return vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: HTMLElement) {
const anchor = this.getAttribute('data-anchor') ?? ''
const top =
this === host
? 0
: order.includes(anchor)
? order.indexOf(anchor) * 40 - 80
: 900
return {
top,
bottom: top,
left: 0,
right: 0,
width: 0,
height: 0,
x: 0,
y: top,
toJSON: () => ({}),
}
})
}
it('marks Content types in the index and on the Jump to select', async () => {
const host = scrollerHost()
const wrapper = await mountIntegration(host)
const rect = stubOffsets(host)
host.dispatchEvent(new Event('scroll'))
await flushPromises()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button[aria-current="true"]').map((item) => item.text())).toEqual([
'Content types',
])
expect(wrapper.get('button[aria-label="Jump to section"]').text()).toContain(
'Content types',
)
rect.mockRestore()
host.remove()
})
it('marks Content types in the open Jump to list', async () => {
const host = scrollerHost()
const wrapper = await mountIntegration(host)
const rect = stubOffsets(host)
host.dispatchEvent(new Event('scroll'))
await flushPromises()
await wrapper.get('button[aria-label="Jump to section"]').trigger('click')
await flushPromises()
const options = Array.from(document.body.querySelectorAll('[role="option"]'))
const marked = options.filter((option) => option.getAttribute('aria-selected') === 'true')
expect(marked.map((option) => option.textContent?.trim())).toEqual(['Content types'])
rect.mockRestore()
host.remove()
})
})
+11 -7
View File
@@ -104,7 +104,7 @@ describe('Search and sharing group', () => {
.map((item) => item.props('endpoint').path), .map((item) => item.props('endpoint').path),
).toContain('/v1/article/{id}/seo/check') ).toContain('/v1/article/{id}/seo/check')
expect(section(wrapper, 'sites').text()).not.toContain('sitemap') expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
expect(section(wrapper, 'sites').text()).toContain('1 route') expect(section(wrapper, 'sites').text()).toContain('2 routes')
}) })
it('describes the resolved SEO object and shows the real error rows', async () => { it('describes the resolved SEO object and shows the real error rows', async () => {
@@ -163,12 +163,14 @@ describe('Search and sharing group', () => {
'Authentication', 'Authentication',
'Articles', 'Articles',
'Search and sharing', 'Search and sharing',
'Content types',
'Entries',
'Built-in types',
'Media', 'Media',
'Sites and authors',
'Images and variants',
'Users and invites', 'Users and invites',
'Settings', 'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only', 'Session only',
'Errors', 'Errors',
'Notes', 'Notes',
@@ -185,11 +187,13 @@ describe('Search and sharing group', () => {
const wrapper = await mountIntegration() const wrapper = await mountIntegration()
const forbidden = ERROR_ROWS.find((row) => row.status === 403)! const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
expect(forbidden.messages).toHaveLength(3) expect(forbidden.messages).toHaveLength(4)
expect(forbidden.note).toBe(NOTE) expect(forbidden.note).toBe(NOTE)
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text()) const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
expect(notes).toEqual([NOTE, NOTE]) expect(notes.filter((item) => item === NOTE)).toHaveLength(2)
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1) expect(ERROR_ROWS.filter((row) => row.note).map((row) => row.status)).toEqual([
403, 404, 409, 422,
])
}) })
it('makes no /v1 request when opening and expanding the new cards', async () => { it('makes no /v1 request when opening and expanding the new cards', async () => {
+10
View File
@@ -242,6 +242,16 @@ const extraSources: Record<string, Source[]> = {
'8f. Search: session-only results', '8f. Search: session-only results',
'8g. Search: no results', '8g. Search: no results',
'8h. Search: after selecting an endpoint', '8h. Search: after selecting an endpoint',
'9a. Content types: create, curl',
'9b. Entries: list, Response tab',
'9c. Entries: replace, 422 and 409',
'9d. Errors: 404, 409 and 422 rows',
'9e. Session only: 6 sub-groups',
'9f. Search: "entry"',
'9g. Search: "capacity" (body field)',
'9h. Search: "schema_incompatible" (error)',
'9i. Search: "content type" (sections)',
'9j. Navigation: scroll-spy on Content types',
], ],
alias: {}, alias: {},
}, },
+105 -2
View File
@@ -1285,6 +1285,40 @@ async function selectIntegrationMerge(page: Page) {
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
} }
function integrationRowName(route: string) {
const escaped = route.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
return new RegExp(`^${escaped}\\. `)
}
function openIntegrationCard(route: string, anchor: string, tab?: string) {
return async (page: Page) => {
await page.getByRole('button', { name: integrationRowName(route) }).click()
await page
.locator(`[data-anchor="${anchor}"]`)
.getByText('Errors', { exact: true })
.waitFor()
await page.waitForTimeout(600)
if (tab) {
await page
.locator(`[data-anchor="${anchor}"]`)
.getByRole('tab', { name: tab, exact: true })
.click()
}
}
}
function openIntegrationCardAt(
route: string,
card: string,
scroll: { anchor: string; desktop: number; mobile: number },
tab?: string,
): VisualState['steps'] {
return async (page) => {
await openIntegrationCard(route, `ep-${card}`, tab)(page)
await scrollToAnchor(page, scroll.anchor, scroll)
}
}
function integrationImagesState( function integrationImagesState(
list: ApiMock | null, list: ApiMock | null,
overrides: Partial<VisualState> & { session?: ApiMock } = {}, overrides: Partial<VisualState> & { session?: ApiMock } = {},
@@ -8710,7 +8744,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}, },
}), }),
'6. Errors': integrationState({ '6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }), steps: (page) => scrollToAnchor(page, 'errors', { desktop: -122, mobile: 55 }),
exception: { exception: {
maxPercent: { desktop: 16, mobile: 22 }, maxPercent: { desktop: 16, mobile: 22 },
reason: 'The board lacks the 409 row, the "Message" heading and the Sites and Authors contents entries', reason: 'The board lacks the 409 row, the "Message" heading and the Sites and Authors contents entries',
@@ -8738,6 +8772,75 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
approvedBy: 'Florian Sylvain', approvedBy: 'Florian Sylvain',
}, },
}), }),
'9a. Content types: create, curl': integrationState({
steps: async (page) => {
const mobile = (page.viewportSize()?.width ?? 0) < desktopMinWidth
await openIntegrationCard('POST /v1/content-type/', 'ep-ct-create', 'curl')(page)
await scrollToAnchor(page, mobile ? 'ep-ct-create' : 'ct-fields', {
desktop: -30,
mobile: 88,
})
},
exception: {
maxPercent: { desktop: 10, mobile: 45 },
reason: 'The board omits the empty "Query parameters" and "Request body" blocks that the card always draws, and the mobile field table has four columns where the board stacks the rule under the name',
approvedBy: 'Florian Sylvain',
},
}),
'9b. Entries: list, Response tab': integrationState({
steps: openIntegrationCardAt(
'GET /v1/content-type/{key}/entry',
'en-list',
{ anchor: 'ep-en-list', desktop: -44, mobile: 88 },
'Response',
),
exception: {
maxPercent: { desktop: 10, mobile: 41 },
reason: 'The board omits the empty "Query parameters" and "Request body" blocks that the card always draws, and the mobile field table has four columns where the board stacks the rule under the name',
approvedBy: 'Florian Sylvain',
},
}),
'9c. Entries: replace, 422 and 409': integrationState({
steps: openIntegrationCardAt('PUT /v1/content-type/{key}/entry/{id}', 'en-update', {
anchor: 'ep-en-update',
desktop: -108,
mobile: 88,
}),
exception: {
maxPercent: { desktop: 10, mobile: 42 },
reason: 'The board omits the empty "Query parameters" and "Request body" blocks that the card always draws, and the mobile field table has four columns where the board stacks the rule under the name',
approvedBy: 'Florian Sylvain',
},
}),
'9d. Errors: 404, 409 and 422 rows': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: -122, mobile: 55 }),
exception: {
maxPercent: { desktop: 15, mobile: 22 },
reason: 'The app joins the error messages with " / " and puts the note under them, where the board writes prose with the note in another position',
approvedBy: 'Florian Sylvain',
},
}),
'9e. Session only: 6 sub-groups': integrationState({
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: -107 }),
}),
'9f. Search: "entry"': integrationState({ steps: searchingIntegration('entry') }),
'9g. Search: "capacity" (body field)': integrationState({
steps: searchingIntegration('capacity'),
}),
'9h. Search: "schema_incompatible" (error)': integrationState({
steps: searchingIntegration('schema_incompatible'),
}),
'9i. Search: "content type" (sections)': integrationState({
steps: searchingIntegration('content type'),
}),
'9j. Navigation: scroll-spy on Content types': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'ctypes', { desktop: -5, mobile: -3 })
if ((page.viewportSize()?.width ?? 0) < desktopMinWidth) {
await page.getByRole('button', { name: 'Jump to section' }).click()
}
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants), 'I1 · Many variants': integrationImagesState(integrationManyVariants),
'I2 · One variant': integrationImagesState( 'I2 · One variant': integrationImagesState(
integrationVariantsList(integrationVariantRows.slice(0, 1)), integrationVariantsList(integrationVariantRows.slice(0, 1)),
@@ -8794,7 +8897,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}), }),
'7. Navigation': integrationState({ '7. Navigation': integrationState({
steps: async (page) => { steps: async (page) => {
await scrollToAnchor(page, 'media', { desktop: -5, mobile: -3 }) await scrollToAnchor(page, 'media', { desktop: -81, mobile: -3 })
if ((page.viewportSize()?.width ?? 0) < 1024) { if ((page.viewportSize()?.width ?? 0) < 1024) {
await page.getByRole('button', { name: 'Jump to section' }).click() await page.getByRole('button', { name: 'Jump to section' }).click()
} }