feat: integration page (PART 3/3)

This commit is contained in:
fsylvain committed 2026-10-05 02:03:24 +02:00
1 parent 514a082c96
commit acd392c29d
20 files changed
+3115 -409

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
# DB
*.db
data/
/data/
# Build
bin
+39
View File
@@ -0,0 +1,39 @@
# API reference
The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI.
## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
for example `https://cms.example/v1/article/`.
## Authentication
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
articles only.
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
creator, with that user's role, and shows in full once.
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
or revoked key answers 401, even on public routes.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
## Routes a key can call (20)
| Group | Routes |
| --- | --- |
| Articles | 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} |
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
| Settings | GET /v1/settings |
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
## Errors
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
## Notes
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
- Browsers can call the API only from origins in the server's CORS setting.
+182 -52
View File
@@ -1,63 +1,193 @@
Feature: Integration screen
Feature: Integration screen (API reference)
As a verified RenewCMS owner
I want /integration to show my site's base API URL and ready-to-use endpoint examples
So that I, or the developer who builds my public site, can wire it up without reading the
source
As a developer building a frontend or an API-key client for a RenewCMS site
I want /integration to be a complete, searchable API reference
So that I can call every route a key may call without reading the source
Background:
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
inside DashboardShell, per docs/design/routes.md
And the screen makes no /v1 calls; api: none (static)
And the PageHeader shows title "Connect your website", description "A guide for the developer
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
actions
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
articles but doesn't show them to visitors. Your website fetches published articles from the
two addresses below and displays them in its own design."
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
(icon copy) beside it
And two EndpointCard entries follow, in this order:
| method | path | description |
| GET | /v1/article/ | Lists every online article. |
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
And each EndpointCard's fetch example uses the current origin as the base of its URL:
| path | fetch example |
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
And the curl tab's content is a plain curl command built from the current origin (e.g.
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
content is a JSON sample built from the real domain Article fields; neither tab is active by
default, and neither is visually checked, since the fetch tab is the one shown in the board
And three info notes follow the endpoint cards, in this order:
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
them."
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
base URL before rendering."
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
whoever runs the server to add your site's address."
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
"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 catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order:
| 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} |
| 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 |
| 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 |
And the "Session only" group has 13 non-expandable rows in 3 sub-groups:
| 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 |
| 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 |
| API key management | GET /v1/api-key, POST /v1/api-key, DELETE /v1/api-key/{id} |
And a "Public auth flow" sub-list follows with 6 non-expandable rows and the chip "Public":
| method | path | description |
| GET | /v1/ | Hello world. |
| GET | /v1/auth/registration | Whether registration is open. |
| POST | /v1/auth/password-reset/confirm | Set a new password with the emailed code. |
| GET | /v1/auth/invite | Check an invite code. |
| POST | /v1/auth/invite/accept | Accept an invite. |
| GET | /healthz | Plain text "ok". Outside /v1. |
And every catalogue endpoint has a non-empty description, method, path, access chip, key chip,
query-parameter table (or "No query parameters."), request-body table (or "No request body."),
fetch, curl and Response examples, and error captions
Scenario: Default — the page loads with the app's own origin
Given the app is served from origin "{origin}"
Scenario: Default, every card collapsed
When the user navigates to /integration
Then the Base URL row shows exactly "{origin}"
And the Base URL "Copy" button reads "Copy", not "Copied"
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
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."
And a "Base URL" row shows window.location.origin in a monospace box with 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 the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
Scenario: Copied — the Base URL copy button
Given the user is on /integration, served from origin "{origin}"
When the user activates the Base URL row's "Copy" button
Scenario Outline: Method badge and chips per endpoint
Then <method> <path> shows the <method> badge, access chip "<access>" and key chip "API key OK"
Examples:
| method | path | access |
| GET | /v1/article/ | Public |
| GET | /v1/article/{id} | Public |
| POST | /v1/article/ | Verified user |
| DELETE | /v1/article/{id} | Verified user |
| GET | /v1/media/ | Verified user |
| PATCH | /v1/user/me | Verified user |
| GET | /v1/auth/me | Verified user |
| POST | /v1/user/invite | Owner only |
| DELETE | /v1/user/invite/{id} | Owner only |
| POST | /v1/user/invite/{id}/renew | Owner only |
| GET | /v1/settings | Verified user |
Scenario: Owner-only invite cards, user-approved divergence from the board
Then the three invite cards show "Owner only", where the board shows "Verified user"
And each lists a 403 error "Only the owner can do this."
And each says it is rate limited and that too many requests answer 429, with no number
Scenario: Group scrolled, one card expanded
When the user activates the "POST /v1/article/" row
Then the row's button reports aria-expanded "true"
And a "Query parameters" table lists "format", string, optional, "markdown, html or json. Default markdown."
And a "Request body" table lists:
| Name | Type | Required | Rule |
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
| body | string | Yes | Up to 10 000 characters. |
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
And an "Errors" list shows a status chip with a one-line caption for 400, 401
Scenario: Query parameters table, new UI beyond the board, user-approved
Then the "Query parameters" table sits above the "Request body" table in each card
And it lists:
| route | name | type | required | rule |
| every /v1/article route | format | string | no | markdown, html or json. Default markdown. |
| GET /v1/media/ | q | string | no | Search text. |
| GET /v1/media/ | hash | string | no | SHA-256 of the content, lowercased. |
| GET /v1/media/ | cursor | string | no | The next_cursor of the previous page. |
| GET /v1/media/ | filter | string | no | all, unused or duplicates. Default all. |
| GET /v1/media/ | sort | string | no | newest, oldest, name or largest. Default newest. |
| GET /v1/media/ | limit | number | no | 1 to 100. Default 24. |
And a card without query parameters shows "No query parameters."
Scenario Outline: Request body fields come from the handler validate tags
Then <method> <path> lists these fields: <fields>
Examples:
| method | path | fields |
| POST | /v1/article/ | title string required 3-200; body string required max 10000 |
| PATCH | /v1/article/{id} | body string required max 10000 |
| PATCH | /v1/media/{id} | filename string required |
| POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 |
| POST | /v1/media/ | multipart/form-data, 1 file field named "file" |
| PATCH | /v1/user/me | display_name string required max 60 |
| POST | /v1/user/invite | email string required valid email |
| GET | /v1/article/ | "No request body." |
Scenario: Response samples use the real domain JSON field names
Then the GET /v1/article/{id} Response sample has exactly the keys id, title, body, body_format, is_online, created_at, updated_at
And the sample has no "images" key and no "status" key
And POST /v1/media/merge has the keys articles_updated and merged
And GET /v1/media/ has the keys items, next_cursor and counts
And GET /v1/media/{id}/usage items have the keys id, title, published
And PATCH /v1/article/{id}/publish and /unpublish samples are articles with is_online true and false
Scenario: Article body format
Then a note reads "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."
And every article card's Errors list includes 400 "The format must be one of: markdown, html, json."
Scenario: Authentication section
Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id}
And card "API key" reads "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."
And its CodeBlock 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 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 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
Then the banner reads "These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session.""
And each row has a dashed border and muted fill, uses no method colour and cannot be expanded
And rows that answer 403 to a key show the lock icon and the chip "Session only"
And Log in, Log out, Register and Reset password show the caption "Made for the browser. A key is ignored here, with no 403." and no 403 claim
And no /v1/system route appears in any expandable group
And the "Public auth flow" rows carry the chip "Public" and no lock icon
Scenario: Errors section
Then the intro reads "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."
And the table lists:
| 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. |
| 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. |
| 404 | Article not found / Image not found / Invite not found / API key not found |
| 409 | There's already a pending invite for this address. / This invite has already been accepted. |
| 429 | Too many requests. Please try again later. |
| 500 | Internal server error |
And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
And the table does not use the SPA copy of docs/design/states.md
Scenario Outline: Per-endpoint error captions use the real strings
Then <method> <path> lists <status> "<caption>"
Examples:
| method | path | status | caption |
| POST | /v1/media/ | 400 | the image could not be read |
| POST | /v1/media/ | 413 | the file exceeds the maximum upload size |
| POST | /v1/media/ | 415 | the file must be a PNG, JPEG, WEBP, or SVG image |
| GET | /v1/media/ | 400 | The media query is invalid. |
| POST | /v1/media/merge | 400 | Choose an image to keep and at least one other image to merge into it. |
| POST | /v1/media/merge | 409 | Some of the images to merge changed. Refresh and try again. |
| PATCH | /v1/media/{id} | 400 | Enter a filename. |
| POST | /v1/article/ | 400 | Article title must be between 3 and 200 characters. |
| GET | /v1/article/{id} | 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
| PATCH | /v1/user/me | 400 | Display name must be 60 characters or fewer. |
| POST | /v1/user/invite | 409 | This address already has an account. |
| POST | /v1/user/invite | 409 | There's already a pending invite for this address. |
| POST | /v1/user/invite/{id}/renew | 409 | This invite has already been accepted. |
| POST | /v1/user/invite | 403 | Only the owner can do this. |
Scenario: Notes
Then the image-prefix note reads "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering."
And the CORS note reads "Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address."
Scenario: Copied, Base URL
Given the app is served from origin "{origin}"
When the user activates the Base URL "Copy" button
Then "{origin}" is written to the clipboard
And the button's icon changes to check and its text changes to "Copied"
And the change is announced to assistive technology
When 2 seconds elapse without a further click
Then the button reverts to icon copy and text "Copy"
And the button shows icon check and "Copied", announced to assistive technology
When 2 seconds elapse
Then the button reverts to icon copy and "Copy"
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
Given the user is on /integration
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
button
Then that card's own code is written to the clipboard, and only that button reads "Copied"
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
Scenario: Copied, CodeBlock is independent of Base URL
Given a card is expanded
When the user activates that CodeBlock's "Copy JavaScript example" button
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
And the Base URL button and other CodeBlocks are unaffected
Scenario: Examples use the current origin and the API key header
Then every fetch and curl example starts from "{origin}" and carries no ?format= query
And examples for non-public routes carry "Authorization: Bearer rcms_…"
And examples for public routes carry no Authorization header
Scenario: Navigation
When the user activates a section in the index (desktop)
Then the page scrolls to that anchor and the item reports aria-current "true"
And on mobile the "Jump to" select opens and lists the same sections
Scenario: The page stays static
Then no /v1 request is made while the page renders or while any card is expanded
+86 -54
View File
@@ -1,66 +1,98 @@
# QA: Integration screen
# QA: Integration screen (API reference)
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
`/v1` mocks are needed; the screen makes no calls.
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
is made.
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
"Connect your website" and description "A guide for the developer who builds your public
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
the three info notes render in order.
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
button reverts to icon copy and text "Copy".
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
Vitest-only; it has no visual-check state of its own (see Visual below).
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
content is a JSON sample built from the real domain Article fields. Neither tab's content is
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
1. The catalogue holds exactly the 20 expected key-callable routes from an explicit hand-maintained
list in the spec (Articles 7, Media 6, Users and invites 6, Settings 1), each once, with no other
expandable route. Session only has the 13 rows and Public auth flow the 6 rows listed in the
feature, from the same kind of explicit list.
2. Every catalogue route has a non-empty `description`. An empty or missing one fails the spec and
names the route.
3. Every route has method, path, access chip, key chip, a query-parameter list and a body-field list
(possibly empty), and fetch, curl and Response examples.
4. The Response sample of each route parses as JSON and uses only keys from a fixture of the real
fields:
- article: id, title, body, body_format, is_online, created_at, updated_at;
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
created_at, used_in, duplicate;
- media list: items, next_cursor, counts (all, unused, duplicates);
- merge: articles_updated, merged;
- usage: id, title, published;
- user list item: id, kind, username, display_name, email, role, status, created_at;
- invite: id, kind, email, status, created_at, expires_at;
- settings: version.
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
article route, and q, hash, cursor, filter, sort, limit on GET /v1/media/.
6. The three invite routes carry "Owner only", a 403 "Only the owner can do this." caption and the
rate-limit sentence. No /v1/system route is in an expandable group. Login, Log out, Register and
Reset password rows carry no 403 claim. Verify, DELETE /auth/register, /api-key/* and
/system/* are the only rows that claim 403.
7. Error captions equal fixtures copied from api/pkg/apperr/messages.go and the image domain
errors (413, 415, 400 on upload).
Component specs, one per design state:
1. **Default**: breadcrumb, title "API reference", description, intro, Base URL equals the stubbed
origin, all cards collapsed (aria-expanded "false"), section index entries present, the three
invite rows show "Owner only".
2. **Group scrolled, one card expanded**: click the POST /v1/article/ row. Assert the Query
parameters table above the Request body table, the body rows, tabs fetch/curl/Response with fetch
active, the "JavaScript" CodeBlock, and the Errors list with 400 and 401.
3. **Copied: base URL**: fake timers; click Copy. Clipboard gets the origin; check icon and "Copied";
announced; reverts after 2 s.
4. **Copied: CodeBlock**: expand a card; click "Copy JavaScript example". Clipboard gets that code,
only that button reads "Copied", and it reverts after 2 s.
5. **Authentication**: both cards, curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
href /integration/api-keys, Note 1, Note 2.
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
the "Public" chip.
7. **Errors**: table rows 400, 401, 403, 404, 409, 429, 500 with the real strings and the 403
sample block.
8. **Navigation**: clicking an index item sets aria-current and scrolls to its anchor; the mobile
"Jump to" lists the same sections.
9. **Tabs**: curl and Response render for a card. curl contains the stubbed origin and the
Authorization header only on non-public routes. Not visually checked.
10. **Notes**: format note, image-prefix note, CORS note.
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
Reference: `docs/design/screens/integration.md` and
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
Reference: docs/design/screens/integration.md; board "Integration" drawn by
docs/design/source/IntegrationPage.dc.html. Serve the SPA under `https://cms.renew.example`. Mocks
reuse the board's script data: BASE, KEY, group and endpoint descriptions, the request bodies
(for example the "Choosing tiles for a small bathroom" article). Response samples are not compared;
the Response tab is never active in a board frame.
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
Playwright request routing, so the real `window.location.origin` the running app reads matches the
board's sample text exactly; no production test hook or origin stub is needed in app code.
Pre-approved exceptions: the user pre-approved visual exceptions for Session only, Group scrolled
one card expanded, Errors and Default. For each of these four states the coder measures the real
diff, sets the smallest `maxPercent` that is sufficient, writes the reason, and records
`approvedBy` "Florian Sylvain". The other states must still pass 10 %.
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
`https://cms.renew.example`, served as above.
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
color, matching the board's `copied: true` frame. This state is timer-driven, not
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
elsewhere in the app (UserMenu, ArticleTable row menu).
- [ ] Default (exception: owner-only chips and rate-limit line)
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
- [ ] Copied: base URL (must pass 10 %)
- [ ] Copied: CodeBlock (must pass 10 %)
- [ ] Authentication (must pass 10 %; Note 1 text changed)
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
- [ ] Errors (exception: 409 row)
- [ ] Navigation (must pass 10 %)
## Manual verification
None of these states is clock-sensitive, so none is skipped.
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
## Manual
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
- Hover and motion on ghost Copy buttons and rows.
- Find-in-page and a deep link to an `#ep-...` anchor.
- Real origin in a production build and under `task dev`.
- Run each documented example against a running server with a real `rcms_` key, including the
403 on a session-only route and the owner-only 403 for an admin key on the invite routes. This
needs a real server and is not automated.
- docs/api.md matches the catalogue (hand check).
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ApiField } from '@/data/apiCatalogue'
defineProps<{
fields: ApiField[]
}>()
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
accessorKey: 'name',
get header() {
return t('Name')
},
...fixed('w-33'),
},
{
accessorKey: 'type',
get header() {
return t('Type')
},
...fixed('w-21'),
},
{
accessorKey: 'required',
get header() {
return t('Required')
},
...fixed('w-21'),
},
{
accessorKey: 'rule',
get header() {
return t('Rule')
},
},
])
</script>
<template>
<UTable
:data="fields"
:columns="columns"
:ui="{
root: 'border border-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 #name-cell="{ row }">
<span class="font-mono text-xs font-medium break-words text-highlighted">{{
row.original.name
}}</span>
</template>
<template #type-cell="{ row }">
<span class="font-mono text-xs">{{ row.original.type }}</span>
</template>
<template #required-cell="{ row }">
{{ row.original.required ? t('Yes') : t('No') }}
</template>
<template #rule-cell="{ row }">
<span class="text-pretty">{{ row.original.rule }}</span>
</template>
</UTable>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HttpMethod } from '@/data/apiCatalogue'
defineProps<{
method: HttpMethod
path: string
description: string
}>()
</script>
<template>
<div
class="flex min-h-9 flex-col gap-0.5 px-3.5 py-2 lg:grid lg:grid-cols-[280px_minmax(0,1fr)_auto] lg:items-center lg:gap-4 lg:py-0"
>
<span class="flex min-w-0 items-center gap-2.5">
<span class="w-14 shrink-0 font-mono text-xs font-medium text-muted">{{ method }}</span>
<span class="min-w-0 truncate font-mono text-sm leading-4.25 text-toned">{{
path
}}</span>
</span>
<span class="text-muted">{{ description }}</span>
<slot />
</div>
</template>
+4 -4
View File
@@ -30,7 +30,7 @@ function onCopy() {
<template>
<div class="bg-elevated">
<div class="flex h-8 items-center justify-between border-b border-default pr-2 pl-4">
<div class="flex h-8 items-center justify-between border-b border-default pr-1 pl-3">
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
label ?? lang
}}</span>
@@ -41,7 +41,7 @@ function onCopy() {
size="xs"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
class="gap-1.5 px-2 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
@@ -51,13 +51,13 @@ function onCopy() {
<div
v-if="highlightedHtml"
tabindex="0"
class="shiki-block overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre"
class="shiki-block overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre"
v-html="highlightedHtml"
/>
<pre
v-else
tabindex="0"
class="overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre text-default"
class="overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre text-default"
>{{ code }}</pre
>
</div>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { splitInlineCode } from '@/lib/apiReference'
defineProps<{
text: string
}>()
</script>
<template>
<template v-for="(part, index) in splitInlineCode(text)" :key="index">
<code v-if="part.code" class="font-mono text-xs text-highlighted">{{ part.value }}</code>
<template v-else>{{ part.value }}</template>
</template>
</template>
+154 -54
View File
@@ -1,18 +1,42 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, translate } from '@/i18n'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import ApiFieldTable from '@/components/ApiFieldTable.vue'
import CodeBlock from '@/components/CodeBlock.vue'
import {
RATE_LIMIT_SENTENCE,
NO_CONTENT_RESPONSE,
endpointExamples,
type ApiEndpoint,
} from '@/data/apiCatalogue'
import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
defineProps<{
method: 'GET'
path: string
description: string
examples: { fetch: string; curl: string; response: string }
const props = defineProps<{
endpoint: ApiEndpoint
origin: string
}>()
const open = ref(false)
const activeTab = ref('fetch')
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
const rowLabel = computed(
() => `${props.endpoint.method} ${props.endpoint.path}. ${props.endpoint.description}`,
)
const bodyTitle = computed(() => {
if (props.endpoint.bodyKind === 'multipart') {
return t('Request body · multipart/form-data, 1 file field')
}
return props.endpoint.bodyKind === 'json' ? t('Request body · JSON') : t('Request body')
})
const responseLabel = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'HTTP' : 'JSON',
)
const responseLang = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'text' : 'json',
)
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
@@ -26,55 +50,131 @@ const tabItems = [
</script>
<template>
<UCard
:ui="{
root: 'border border-default ring-0 divide-default',
header: 'p-4 sm:p-4',
body: 'p-0 sm:p-0',
}"
<UCollapsible
v-model:open="open"
:data-anchor="endpointAnchor(endpoint.id)"
class="scroll-mt-22 lg:scroll-mt-14 border-t border-default first:border-t-0"
:ui="{ content: 'border-t border-muted' }"
>
<template #header>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2.5">
<UBadge :label="method" color="neutral" variant="solid" class="font-mono" />
<span class="font-mono text-base font-medium text-highlighted">{{ path }}</span>
<button
type="button"
:aria-label="rowLabel"
class="flex min-h-11 w-full cursor-pointer flex-col items-stretch gap-1.5 px-3 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary lg:grid lg:grid-cols-[300px_minmax(0,1fr)_auto_16px] lg:items-center lg:gap-4 lg:px-4 lg:py-0"
:class="open ? 'bg-muted' : ''"
>
<span class="flex min-w-0 items-center gap-2.5">
<UBadge
:label="endpoint.method"
color="neutral"
variant="outline"
:class="METHOD_CLASS[endpoint.method]"
/>
<span class="min-w-0 truncate font-mono text-sm font-medium text-highlighted">{{
endpoint.path
}}</span>
<span class="flex-1 lg:hidden" />
<UIcon
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-4 shrink-0 text-muted lg:hidden"
/>
</span>
<span class="min-w-0 text-toned lg:truncate">{{ endpoint.description }}</span>
<span class="flex flex-wrap gap-1.5">
<UBadge
:label="translate(endpoint.access)"
color="neutral"
variant="outline"
:class="
endpoint.access === 'Owner only' ? CHIP_CLASS.strong : CHIP_CLASS.neutral
"
/>
<UBadge
:label="t('API key OK')"
icon="i-lucide-check"
color="success"
variant="soft"
:class="CHIP_CLASS.success"
:ui="{ leadingIcon: 'size-3' }"
/>
</span>
<UIcon
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="hidden size-4 text-muted lg:block"
/>
</button>
<template #content>
<div class="flex flex-col gap-3.5 px-3 pt-2.5 pb-4 lg:px-4">
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ t('Query parameters') }}</span>
<ApiFieldTable v-if="endpoint.query.length > 0" :fields="endpoint.query" />
<span v-else class="text-muted">{{ t('No query parameters.') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ bodyTitle }}</span>
<ApiFieldTable v-if="endpoint.body.length > 0" :fields="endpoint.body" />
<span v-else class="text-muted">{{ t('No request body.') }}</span>
</div>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned max-lg:h-11',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<CodeBlock
v-else-if="item.value === 'curl'"
label="Shell"
lang="shell"
:code="examples.curl"
/>
<CodeBlock
v-else
:label="responseLabel"
:lang="responseLang"
:code="examples.response"
/>
</template>
</UTabs>
<div v-if="endpoint.rateLimited" class="flex gap-2.5 text-toned">
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-pretty">{{ translate(RATE_LIMIT_SENTENCE) }}</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ t('Errors') }}</span>
<div
v-for="error in endpoint.errors"
:key="`${error.status}-${error.caption}`"
:title="error.caption"
class="flex items-baseline gap-2.5"
>
<UBadge
:label="String(error.status)"
color="neutral"
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"
/>
<span class="min-w-0 text-pretty text-toned">{{ error.caption }}</span>
</div>
</div>
<span class="text-toned">{{ description }}</span>
</div>
</template>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0',
list: 'gap-5 border-b border-default px-4 py-0 before:hidden',
trigger: 'h-9 grow-0 px-0 text-sm font-normal data-[state=active]:font-medium',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<pre
v-else-if="item.value === 'curl'"
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.curl }}</pre
>
<pre
v-else
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.response }}</pre
>
</template>
</UTabs>
</UCard>
</UCollapsible>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
export interface IndexSection {
id: string
label: string
}
const props = defineProps<{
sections: IndexSection[]
active: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const activeLabel = computed(
() => props.sections.find((section) => section.id === props.active)?.label ?? '',
)
function onJump(id: string) {
emit('select', id)
}
</script>
<template>
<nav
:aria-label="t('On this page')"
class="sticky top-0 hidden w-42 shrink-0 flex-col gap-0.5 self-start border-l border-default lg:flex"
>
<span
class="px-3 pb-1.5 text-xs leading-5 font-medium tracking-wide text-muted uppercase"
>{{ t('On this page') }}</span
>
<button
v-for="section in sections"
:key="section.id"
type="button"
:aria-current="section.id === active ? 'true' : undefined"
class="-ml-px flex h-7 cursor-pointer items-center border-l-2 px-3 text-left hover:text-highlighted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="
section.id === active
? 'border-primary font-medium text-highlighted'
: 'border-transparent text-toned'
"
@click="onJump(section.id)"
>
{{ section.label }}
</button>
</nav>
<div
class="sticky -top-5 z-5 order-first -mx-4 -mt-5 mb-5 border-b border-default bg-default px-4 py-2 lg:hidden"
>
<USelectMenu
:model-value="active"
:items="sections"
value-key="id"
label-key="label"
trailing-icon="i-lucide-chevrons-up-down"
:search-input="false"
:aria-label="t('Jump to section')"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
trailingIcon: 'size-4 text-muted',
content: 'max-h-none w-(--reka-popper-anchor-width) border border-default',
item: 'h-11 px-2.5 text-base text-highlighted',
}"
@update:model-value="onJump($event as string)"
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="text-muted">{{ t('Jump to') }}</span>
<span class="flex-1 truncate text-left font-medium text-highlighted">{{
activeLabel
}}</span>
</span>
</USelectMenu>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import ApiRouteRow from '@/components/ApiRouteRow.vue'
import type { PublicRow, SessionGroup } from '@/data/apiCatalogue'
import { CHIP_CLASS } from '@/lib/apiReference'
defineProps<{
groups: SessionGroup[]
publicRows: PublicRow[]
}>()
const SUBGROUP_TITLE_CLASS =
'px-3.5 pt-2 pb-1 text-xs font-medium tracking-wide text-muted uppercase'
</script>
<template>
<div class="flex flex-col gap-3">
<div class="border border-dashed border-accented bg-muted">
<div class="flex gap-2.5 px-3.5 py-3 text-pretty text-toned">
<UIcon name="i-lucide-lock" class="mt-0.5 size-4 shrink-0 text-muted" />
<span>{{
t(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
}}</span>
</div>
<div
v-for="group in groups"
:key="group.title"
class="border-t border-dashed border-accented"
>
<div :class="SUBGROUP_TITLE_CLASS">{{ translate(group.title) }}</div>
<ApiRouteRow
v-for="row in group.rows"
:key="`${row.method} ${row.path}`"
:method="row.method"
:path="row.path"
:description="row.description"
data-session-row
>
<UBadge
v-if="row.answers403"
:label="t('Session only')"
icon="i-lucide-lock"
color="neutral"
variant="outline"
:class="[CHIP_CLASS.dashed, 'self-start lg:self-center']"
:ui="{ leadingIcon: 'size-3' }"
/>
</ApiRouteRow>
</div>
</div>
<div class="border border-default bg-default">
<div :class="SUBGROUP_TITLE_CLASS">{{ t('Public auth flow') }}</div>
<ApiRouteRow
v-for="row in publicRows"
:key="`${row.method} ${row.path}`"
:method="row.method"
:path="row.path"
:description="row.description"
data-public-row
>
<UBadge
:label="t('Public')"
color="neutral"
variant="outline"
:class="[CHIP_CLASS.neutral, 'self-start lg:self-center']"
/>
</ApiRouteRow>
</div>
</div>
</template>
+1 -1
View File
@@ -6,7 +6,7 @@ export function useShikiHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ['github-light', 'github-dark'],
langs: ['javascript'],
langs: ['javascript', 'shellscript', 'json'],
})
}
return highlighterPromise
+559
View File
@@ -0,0 +1,559 @@
import { describe, expect, it } from 'vitest'
import {
API_GROUPS,
API_KEY_PLACEHOLDER,
ERROR_ROWS,
LOGIN_FAMILY_CAPTION,
NO_CONTENT_RESPONSE,
OWNER_REQUIRED_MESSAGE,
PUBLIC_AUTH_ROWS,
RATE_LIMIT_SENTENCE,
SESSION_GROUPS,
SESSION_REQUIRED_MESSAGE,
allEndpoints,
endpointExamples,
type ApiEndpoint,
} from '../apiCatalogue'
const ORIGIN = 'https://cms.renew.example'
const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'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}',
],
Media: [
'GET /v1/media/',
'POST /v1/media/',
'PATCH /v1/media/{id}',
'DELETE /v1/media/{id}',
'POST /v1/media/merge',
'GET /v1/media/{id}/usage',
],
'Users and invites': [
'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: ['GET /v1/settings'],
}
const EXPECTED_SESSION: Record<string, string[]> = {
'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',
],
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
}
const EXPECTED_PUBLIC = [
'GET /v1/',
'GET /v1/auth/registration',
'POST /v1/auth/password-reset/confirm',
'GET /v1/auth/invite',
'POST /v1/auth/invite/accept',
'GET /healthz',
]
const ARTICLE_KEYS = ['id', 'title', 'body', 'body_format', 'is_online', 'created_at', 'updated_at']
const MEDIA_ITEM_KEYS = [
'id',
'filename',
'url',
'size',
'content_type',
'width',
'height',
'sha256',
'uploaded_by',
'created_at',
'used_in',
'duplicate',
]
const USER_LIST_KEYS = [
'id',
'kind',
'username',
'display_name',
'email',
'role',
'status',
'created_at',
]
const INVITE_KEYS = ['id', 'kind', 'email', 'status', 'created_at', 'expires_at']
const ME_KEYS = [
'id',
'username',
'email',
'is_verified',
'created_at',
'updated_at',
'display_name',
'role',
]
const label = (item: { method: string; path: string }) => `${item.method} ${item.path}`
function find(route: string) {
const found = allEndpoints().find((endpoint) => label(endpoint) === route)
if (!found) {
throw new Error(`catalogue has no ${route}`)
}
return found
}
function parsed(endpoint: ApiEndpoint) {
return JSON.parse(endpoint.response)
}
function keysOf(value: unknown) {
return Object.keys(value as Record<string, unknown>).sort()
}
function expectOnlyKeys(value: unknown, allowed: string[]) {
expect(keysOf(value).filter((key) => !allowed.includes(key))).toEqual([])
}
describe('Catalogue routes', () => {
it('holds exactly the 20 key-callable routes in 4 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(20)
})
it('holds the 13 session-only rows in 3 sub-groups', () => {
expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(13)
})
it('holds the 6 public auth flow rows', () => {
expect(PUBLIC_AUTH_ROWS.map(label)).toEqual(EXPECTED_PUBLIC)
})
it('has no /v1/system route in an expandable group', () => {
expect(allEndpoints().filter((endpoint) => endpoint.path.startsWith('/v1/system'))).toEqual(
[],
)
})
it('gives every route, group, session row and public row a non-blank description', () => {
const described = [
...API_GROUPS.map((group) => [`group ${group.title}`, group.description] as const),
...allEndpoints().map((item) => [label(item), item.description] as const),
...SESSION_GROUPS.flatMap((group) => group.rows).map(
(item) => [label(item), item.description] as const,
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(4 + 20 + 13 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
it.each(Object.values(EXPECTED_GROUPS).flat())(
'describes %s with a description that is not its path',
(route) => {
const item = find(route)
expect(item.description.trim().length, `${route} has no description`).toBeGreaterThan(5)
expect(item.description).not.toContain(item.path)
},
)
})
describe('Catalogue completeness', () => {
it.each(allEndpoints().map((endpoint) => [label(endpoint), endpoint] as const))(
'%s has method, path, chips, tables and examples',
(_route, endpoint) => {
const examples = endpointExamples(endpoint, ORIGIN)
expect(['GET', 'POST', 'PATCH', 'DELETE']).toContain(endpoint.method)
expect(endpoint.path.startsWith('/v1/')).toBe(true)
expect(['Public', 'Verified user', 'Owner only']).toContain(endpoint.access)
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{id}', String(endpoint.pathId))}"`,
)
expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('')
expect(endpoint.errors.length).toBeGreaterThan(0)
for (const error of endpoint.errors) {
expect(error.caption.trim()).not.toBe('')
}
for (const field of [...endpoint.query, ...endpoint.body]) {
expect(field.name.trim()).not.toBe('')
expect(field.rule.trim()).not.toBe('')
}
},
)
it('gives Public to the two article reads and Owner only to the invite routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Public')
.map(label),
).toEqual(['GET /v1/article/', 'GET /v1/article/{id}'])
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner only')
.map(label),
).toEqual([
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
])
})
})
describe('Response samples use the real domain JSON keys', () => {
it('uses the article keys, without images or status', () => {
const single = parsed(find('GET /v1/article/{id}'))
expect(keysOf(single)).toEqual([...ARTICLE_KEYS].sort())
expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list[0], ARTICLE_KEYS)
expect(list[0]).not.toHaveProperty('images')
expect(list[0]).not.toHaveProperty('status')
})
it('returns articles from create, update, publish and unpublish', () => {
for (const route of [
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
]) {
expect(keysOf(parsed(find(route))), route).toEqual([...ARTICLE_KEYS].sort())
}
expect(parsed(find('PATCH /v1/article/{id}/publish')).is_online).toBe(true)
expect(parsed(find('PATCH /v1/article/{id}/unpublish')).is_online).toBe(false)
})
it('uses items, next_cursor and counts for the media list', () => {
const list = parsed(find('GET /v1/media/'))
expect(keysOf(list)).toEqual(['counts', 'items', 'next_cursor'])
expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS)
})
it('uses media item keys for upload and rename', () => {
expectOnlyKeys(parsed(find('POST /v1/media/')), MEDIA_ITEM_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/media/{id}')), MEDIA_ITEM_KEYS)
})
it('uses articles_updated and merged for the merge', () => {
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual(['articles_updated', 'merged'])
})
it('uses id, title and published for usage items', () => {
const usage = parsed(find('GET /v1/media/{id}/usage'))
expect(keysOf(usage[0])).toEqual(['id', 'published', 'title'])
})
it('uses user and invite keys for the user list and invites', () => {
const list = parsed(find('GET /v1/user/'))
expectOnlyKeys(list[0], USER_LIST_KEYS)
expectOnlyKeys(list[1], INVITE_KEYS)
expectOnlyKeys(parsed(find('POST /v1/user/invite')), INVITE_KEYS)
expectOnlyKeys(parsed(find('POST /v1/user/invite/{id}/renew')), INVITE_KEYS)
})
it('uses the public user keys for the profile routes', () => {
expectOnlyKeys(parsed(find('GET /v1/auth/me')), ME_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/user/me')), ME_KEYS)
})
it('uses version for the settings', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual(['version'])
})
it('answers 204 for the three deletes without a JSON body', () => {
for (const route of [
'DELETE /v1/article/{id}',
'DELETE /v1/media/{id}',
'DELETE /v1/user/invite/{id}',
]) {
expect(find(route).response, route).toBe(NO_CONTENT_RESPONSE)
}
})
it('parses every other response sample as JSON', () => {
for (const endpoint of allEndpoints().filter(
(item) => item.response !== NO_CONTENT_RESPONSE,
)) {
expect(() => JSON.parse(endpoint.response), label(endpoint)).not.toThrow()
}
})
})
describe('Request bodies and query parameters', () => {
const bodyOf = (route: string) =>
find(route).body.map((field) => [field.name, field.type, field.required])
it('matches the handler validate tags', () => {
expect(bodyOf('POST /v1/article/')).toEqual([
['title', 'string', true],
['body', 'string', true],
])
expect(find('POST /v1/article/').body[0]!.rule).toContain('3 to 200')
expect(find('POST /v1/article/').body[1]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([['body', 'string', true]])
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
expect(bodyOf('POST /v1/media/merge')).toEqual([
['keep_id', 'number', true],
['merge_ids', 'number[]', true],
])
expect(bodyOf('PATCH /v1/user/me')).toEqual([['display_name', 'string', true]])
expect(find('PATCH /v1/user/me').body[0]!.rule).toContain('60')
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
})
it('uses the multipart field "file" for the upload', () => {
const upload = find('POST /v1/media/')
expect(upload.bodyKind).toBe('multipart')
expect(upload.body.map((field) => field.name)).toEqual(['file'])
})
it('has no request body on reads, publish, unpublish and deletes', () => {
for (const route of [
'GET /v1/article/',
'GET /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'DELETE /v1/article/{id}',
'GET /v1/media/',
'GET /v1/settings',
]) {
expect(find(route).body, route).toEqual([])
}
})
it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(
endpoint.query.map((param) => param.name),
label(endpoint),
).toEqual(['format'])
}
})
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q',
'hash',
'cursor',
'filter',
'sort',
'limit',
])
expect(find('GET /v1/media/').query.find((param) => param.name === 'limit')!.type).toBe(
'number',
)
expect(find('POST /v1/media/').query).toEqual([])
expect(find('GET /v1/settings').query).toEqual([])
})
})
describe('Owner-only invite routes', () => {
const inviteRoutes = [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]
it.each(inviteRoutes)('%s is owner only, rate limited, and lists the 403', (route) => {
const endpoint = find(route)
expect(endpoint.access).toBe('Owner only')
expect(endpoint.rateLimited).toBe(true)
expect(endpoint.errors).toContainEqual({ status: 403, caption: OWNER_REQUIRED_MESSAGE })
expect(RATE_LIMIT_SENTENCE).toContain('rate limited')
expect(RATE_LIMIT_SENTENCE).toContain('429')
expect(RATE_LIMIT_SENTENCE.replace('429', '')).not.toMatch(/\d/)
})
it('does not rate limit other routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.rateLimited)
.map(label),
).toEqual(inviteRoutes)
})
})
describe('Per-endpoint error captions use the real strings', () => {
const captions: [string, number, string][] = [
['POST /v1/media/', 400, 'the image could not be read'],
['POST /v1/media/', 413, 'the file exceeds the maximum upload size'],
['POST /v1/media/', 415, 'the file must be a PNG, JPEG, WEBP, or SVG image'],
['GET /v1/media/', 400, 'The media query is invalid.'],
[
'POST /v1/media/merge',
400,
'Choose an image to keep and at least one other image to merge into it.',
],
[
'POST /v1/media/merge',
409,
'Some of the images to merge changed. Refresh and try again.',
],
['PATCH /v1/media/{id}', 400, 'Enter a filename.'],
['POST /v1/article/', 400, 'Article title must be between 3 and 200 characters.'],
[
'GET /v1/article/{id}',
400,
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).',
],
['PATCH /v1/user/me', 400, 'Display name must be 60 characters or fewer.'],
['POST /v1/user/invite', 409, 'This address already has an account.'],
['POST /v1/user/invite', 409, "There's already a pending invite for this address."],
['POST /v1/user/invite/{id}/renew', 409, 'This invite has already been accepted.'],
['POST /v1/user/invite', 403, 'Only the owner can do this.'],
]
it.each(captions)('%s lists %i "%s"', (route, status, caption) => {
expect(find(route).errors).toContainEqual({ status, caption })
})
it('adds the format error to every article card', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(endpoint.errors, label(endpoint)).toContainEqual({
status: 400,
caption: 'The format must be one of: markdown, html, json.',
})
}
})
it('lists 400 and 401 on POST /v1/article/', () => {
const statuses = find('POST /v1/article/').errors.map((error) => error.status)
expect(statuses).toContain(400)
expect(statuses).toContain(401)
})
})
describe('Session only rows', () => {
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
it('claims 403 only on verify, DELETE register, api-key and system rows', () => {
expect(rows.filter((row) => row.answers403).map(label)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
})
it('captions login, logout, register and password reset without a 403 claim', () => {
const family = rows.filter((row) => !row.answers403)
expect(family.map(label)).toEqual([
'POST /v1/auth/login',
'POST /v1/auth/logout',
'POST /v1/auth/register',
'POST /v1/auth/password-reset',
])
for (const row of family) {
expect(row.description).toContain(LOGIN_FAMILY_CAPTION)
}
})
})
describe('Errors table', () => {
it('lists the real statuses and messages', () => {
expect(ERROR_ROWS.map((row) => [row.status, row.messages])).toEqual([
[
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.']],
[
403,
[
SESSION_REQUIRED_MESSAGE,
'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'],
],
[
409,
[
"There's already a pending invite for this address.",
'This invite has already been accepted.',
],
],
[429, ['Too many requests. Please try again later.']],
[500, ['Internal server error']],
])
})
})
describe('Examples', () => {
it('start from the origin and carry no ?format= query', () => {
for (const endpoint of allEndpoints()) {
const examples = endpointExamples(endpoint, ORIGIN)
expect(examples.fetch, label(endpoint)).toContain(`'${ORIGIN}/v1/`)
expect(examples.curl, label(endpoint)).toContain(`"${ORIGIN}/v1/`)
expect(examples.fetch).not.toContain('?format=')
expect(examples.curl).not.toContain('?format=')
}
})
it('carry the Authorization header on non-public routes only', () => {
for (const endpoint of allEndpoints()) {
const examples = endpointExamples(endpoint, ORIGIN)
const header = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
if (endpoint.access === 'Public') {
expect(examples.curl, label(endpoint)).not.toContain('Authorization')
expect(examples.fetch, label(endpoint)).not.toContain('Authorization')
} else {
expect(examples.curl, label(endpoint)).toContain(header)
expect(examples.fetch, label(endpoint)).toContain(
`Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
)
}
}
})
it('send the multipart field "file" for the upload', () => {
const examples = endpointExamples(find('POST /v1/media/'), ORIGIN)
expect(examples.curl).toContain('-F "file=@north-wall.jpg"')
expect(examples.fetch).toContain("form.append('file', file)")
})
it('start with the rcms_ prefix for the key', () => {
expect(API_KEY_PLACEHOLDER.startsWith('rcms_')).toBe(true)
})
})
+756
View File
@@ -0,0 +1,756 @@
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner only'
export interface ApiField {
name: string
type: string
required: boolean
rule: string
}
export interface ApiError {
status: number
caption: string
}
export type BodyKind = 'none' | 'json' | 'multipart'
export interface ApiEndpoint {
id: string
method: HttpMethod
path: string
description: string
access: AccessLevel
pathId: number | null
query: ApiField[]
bodyKind: BodyKind
body: ApiField[]
bodySample: Record<string, unknown> | null
errors: ApiError[]
response: string
rateLimited: boolean
}
export interface ApiGroup {
key: string
title: string
description: string
endpoints: ApiEndpoint[]
}
export interface SessionRow {
method: HttpMethod
path: string
description: string
answers403: boolean
}
export interface SessionGroup {
title: string
rows: SessionRow[]
}
export interface PublicRow {
method: HttpMethod
path: string
description: string
}
export interface ErrorRow {
status: number
meaning: string
messages: string[]
}
export interface EndpointExamples {
fetch: string
curl: string
response: string
}
export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
export const NO_CONTENT_RESPONSE = '204 No Content'
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.'
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.'
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const UNAUTHORIZED = 'You must be logged in to access this resource.'
const VERIFY_REQUIRED = 'You must verify your account to access this resource.'
const ID_FORMAT =
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
const FORMAT_INVALID = 'The format must be one of: markdown, html, json.'
const TOO_MANY_REQUESTS = 'Too many requests. Please try again later.'
const FORMAT_QUERY: ApiField = {
name: 'format',
type: 'string',
required: false,
rule: 'markdown, html or json. Default markdown.',
}
const MEDIA_QUERY: ApiField[] = [
{ name: 'q', type: 'string', required: false, rule: 'Search text.' },
{
name: 'hash',
type: 'string',
required: false,
rule: 'SHA-256 of the content, lowercased.',
},
{
name: 'cursor',
type: 'string',
required: false,
rule: 'The next_cursor of the previous page.',
},
{
name: 'filter',
type: 'string',
required: false,
rule: 'all, unused or duplicates. Default all.',
},
{
name: 'sort',
type: 'string',
required: false,
rule: 'newest, oldest, name or largest. Default newest.',
},
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
]
const json = (value: unknown) => JSON.stringify(value, null, 2)
const ARTICLE_TITLE = 'How we price a bathroom renovation'
const article = (overrides: Record<string, unknown> = {}) => ({
id: 12,
title: ARTICLE_TITLE,
body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.',
body_format: 'markdown',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
})
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg',
size: 3900000,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
uploaded_by: 'marta',
created_at: '2026-09-10T07:00:00Z',
used_in: 2,
duplicate: false,
...overrides,
})
const me = {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
created_at: '2026-01-05T08:30:00Z',
updated_at: '2026-09-10T07:00:00Z',
display_name: 'Marta Alves',
role: 'owner',
}
const invite = (overrides: Record<string, unknown> = {}) => ({
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
expires_at: '2026-09-13T07:00:00Z',
...overrides,
})
const e = (status: number, caption: string): ApiError => ({ status, caption })
const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)]
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = []
function endpoint(
id: string,
method: HttpMethod,
path: string,
description: string,
access: AccessLevel,
errors: ApiError[],
response: string,
extra: Partial<ApiEndpoint> = {},
): ApiEndpoint {
return {
id,
method,
path,
description,
access,
pathId: path.includes('{id}') ? 12 : null,
query: none,
bodyKind: 'none',
body: none,
bodySample: null,
errors,
response,
rateLimited: false,
...extra,
}
}
const ownerOnly = (
id: string,
method: HttpMethod,
path: string,
description: string,
errors: ApiError[],
response: string,
extra: Partial<ApiEndpoint> = {},
) =>
endpoint(
id,
method,
path,
description,
'Owner only',
[...errors, e(403, OWNER_REQUIRED_MESSAGE), e(429, TOO_MANY_REQUESTS)],
response,
{ rateLimited: true, pathId: path.includes('{id}') ? 7 : null, ...extra },
)
export const API_GROUPS: ApiGroup[] = [
{
key: 'articles',
title: 'Articles',
description: "Read, write and publish articles. Titles can't be changed after creation.",
endpoints: [
endpoint(
'art-list',
'GET',
'/v1/article/',
'List articles. Anonymous callers get online articles only.',
'Public',
articleErrors(),
json([article()]),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-get',
'GET',
'/v1/article/{id}',
'Get one article. Drafts answer 404 to anonymous callers.',
'Public',
articleErrors(...articleIdErrors),
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-create',
'POST',
'/v1/article/',
'Create a draft article.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'Article title must be between 3 and 200 characters.'),
),
),
json(
article({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
}),
),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'title',
type: 'string',
required: true,
rule: "3 to 200 characters. Can't be changed later.",
},
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
},
],
bodySample: {
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
},
},
),
endpoint(
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article.',
'Verified user',
verified(articleErrors(e(400, BODY_MISMATCH), ...articleIdErrors)),
json(article({ body: 'Updated text.', is_online: false })),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
},
],
bodySample: { body: 'Updated text.' },
},
),
endpoint(
'art-pub',
'PATCH',
'/v1/article/{id}/publish',
'Publish an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
json(article({ is_online: true })),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-unpub',
'PATCH',
'/v1/article/{id}/unpublish',
'Unpublish an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
json(article({ is_online: false })),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-del',
'DELETE',
'/v1/article/{id}',
'Delete an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
],
},
{
key: 'media',
title: 'Media',
description: 'The image library. One image can be used in many articles.',
endpoints: [
endpoint(
'med-list',
'GET',
'/v1/media/',
'List images.',
'Verified user',
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
json({
items: [mediaItem()],
next_cursor: null,
counts: { all: 1, unused: 0, duplicates: 0 },
}),
{ query: MEDIA_QUERY },
),
endpoint(
'med-up',
'POST',
'/v1/media/',
'Upload an image (multipart).',
'Verified user',
verified([
e(400, 'the image could not be read'),
e(401, UNAUTHORIZED),
e(413, 'the file exceeds the maximum upload size'),
e(415, 'the file must be a PNG, JPEG, WEBP, or SVG image'),
]),
json(mediaItem()),
{
pathId: null,
bodyKind: 'multipart',
body: [
{
name: 'file',
type: 'file',
required: true,
rule: 'PNG, JPEG, WEBP or SVG.',
},
],
bodySample: { file: '@north-wall.jpg' },
},
),
endpoint(
'med-ren',
'PATCH',
'/v1/media/{id}',
'Rename an image. The URL stays the same.',
'Verified user',
verified([
e(400, 'Enter a filename.'),
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Image not found'),
]),
json(mediaItem({ filename: 'north-wall-01.jpg' })),
{
pathId: 4,
bodyKind: 'json',
body: [
{
name: 'filename',
type: 'string',
required: true,
rule: 'The new filename.',
},
],
bodySample: { filename: 'north-wall-01.jpg' },
},
),
endpoint(
'med-del',
'DELETE',
'/v1/media/{id}',
'Delete an image. Articles that use it show a broken image.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
NO_CONTENT_RESPONSE,
{ pathId: 4 },
),
endpoint(
'med-merge',
'POST',
'/v1/media/merge',
'Merge identical copies into one image.',
'Verified user',
verified([
e(
400,
'Choose an image to keep and at least one other image to merge into it.',
),
e(401, UNAUTHORIZED),
e(409, 'Some of the images to merge changed. Refresh and try again.'),
]),
json({ articles_updated: 5, merged: 2 }),
{
bodyKind: 'json',
body: [
{
name: 'keep_id',
type: 'number',
required: true,
rule: 'Greater than 0. The image to keep.',
},
{
name: 'merge_ids',
type: 'number[]',
required: true,
rule: 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.',
},
],
bodySample: { keep_id: 4, merge_ids: [9, 11] },
},
),
endpoint(
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles that use an image.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
{ pathId: 4 },
),
],
},
{
key: 'users',
title: 'Users and invites',
description: 'People who can sign in, and pending invitations.',
endpoints: [
endpoint(
'usr-list',
'GET',
'/v1/user/',
'List users and invites.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json([
{
id: 1,
kind: 'user',
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-01-05T08:30:00Z',
},
{
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
},
]),
),
endpoint(
'usr-me',
'PATCH',
'/v1/user/me',
'Update my profile.',
'Verified user',
verified([
e(400, BODY_MISMATCH),
e(400, 'Display name must be 60 characters or fewer.'),
e(401, UNAUTHORIZED),
]),
json({ ...me, display_name: 'Marta Alves' }),
{
bodyKind: 'json',
body: [
{
name: 'display_name',
type: 'string',
required: true,
rule: 'Up to 60 characters.',
},
],
bodySample: { display_name: 'Marta Alves' },
},
),
ownerOnly(
'usr-inv',
'POST',
'/v1/user/invite',
'Invite a user by email.',
[
e(400, BODY_MISMATCH),
e(401, UNAUTHORIZED),
e(409, 'This address already has an account.'),
e(409, "There's already a pending invite for this address."),
],
json(invite()),
{
bodyKind: 'json',
body: [
{
name: 'email',
type: 'string',
required: true,
rule: 'A valid email address.',
},
],
bodySample: { email: 'lea.martin@renew.example' },
},
),
ownerOnly(
'usr-cancel',
'DELETE',
'/v1/user/invite/{id}',
'Cancel a pending invite.',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Invite not found')],
NO_CONTENT_RESPONSE,
),
ownerOnly(
'usr-renew',
'POST',
'/v1/user/invite/{id}/renew',
'Renew an expired invite.',
[
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Invite not found'),
e(409, 'This invite has already been accepted.'),
],
json(invite()),
),
endpoint(
'auth-me',
'GET',
'/v1/auth/me',
"Get the current user. With a key, the key's owner.",
'Verified user',
[e(401, UNAUTHORIZED)],
json(me),
),
],
},
{
key: 'settings',
title: 'Settings',
description: 'Read the site settings.',
endpoints: [
endpoint(
'set-get',
'GET',
'/v1/settings',
'Get settings.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json({ version: 'v2.2.0-beta' }),
),
],
},
]
const row = (
method: HttpMethod,
path: string,
description: string,
answers403 = true,
): SessionRow => ({
method,
path,
description: answers403 ? description : `${description}. ${LOGIN_FAMILY_CAPTION}`,
answers403,
})
export const SESSION_GROUPS: SessionGroup[] = [
{
title: 'System · owner only',
rows: [
row('GET', '/v1/system/update', 'Update status'),
row('POST', '/v1/system/update/check', 'Check for update'),
row('POST', '/v1/system/update/download', 'Download update'),
row('POST', '/v1/system/update/restart', 'Restart'),
],
},
{
title: 'Sign in and account',
rows: [
row('POST', '/v1/auth/login', 'Log in', false),
row('POST', '/v1/auth/logout', 'Log out', false),
row('POST', '/v1/auth/register', 'Register', false),
row('POST', '/v1/auth/verify', 'Verify email'),
row('DELETE', '/v1/auth/register', 'Cancel registration'),
row('POST', '/v1/auth/password-reset', 'Reset password', false),
],
},
{
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[] = [
{ method: 'GET', path: '/v1/', description: 'Hello world.' },
{ method: 'GET', path: '/v1/auth/registration', description: 'Whether registration is open.' },
{
method: 'POST',
path: '/v1/auth/password-reset/confirm',
description: 'Set a new password with the emailed code.',
},
{ method: 'GET', path: '/v1/auth/invite', description: 'Check an invite code.' },
{ method: 'POST', path: '/v1/auth/invite/accept', description: 'Accept an invite.' },
{ method: 'GET', path: '/healthz', description: 'Plain text "ok". Outside /v1.' },
]
export const ERROR_ROWS: ErrorRow[] = [
{
status: 400,
meaning: 'Bad request',
messages: [BODY_MISMATCH, 'The expiry must be a date in the future.'],
},
{ status: 401, meaning: 'Unauthorized', messages: [UNAUTHORIZED] },
{
status: 403,
meaning: 'Forbidden',
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
},
{
status: 404,
meaning: 'Not found',
messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
},
{
status: 409,
meaning: 'Conflict',
messages: [
"There's already a pending invite for this address.",
'This invite has already been accepted.',
],
},
{ status: 429, meaning: 'Too many requests', messages: [TOO_MANY_REQUESTS] },
{ status: 500, meaning: 'Server error', messages: ['Internal server error'] },
]
export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\n${SESSION_REQUIRED_MESSAGE}`
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
}
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {
const url = `${origin}${resolvedPath(item)}`
const isPublic = item.access === 'Public'
const isJson = item.bodyKind === 'json' && item.bodySample !== null
const isForm = item.bodyKind === 'multipart'
const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`]
if (!isPublic) curlParts.push(` -H "${authHeader}"`)
if (isJson) {
curlParts.push(` -H "Content-Type: application/json"`)
curlParts.push(` -d '${JSON.stringify(item.bodySample)}'`)
}
if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`)
const options: string[] = []
if (item.method !== 'GET') options.push(`method: '${item.method}'`)
const headers = [
isPublic ? null : `Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
isJson ? `'Content-Type': 'application/json'` : null,
].filter((header) => header !== null)
if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`)
if (isJson) options.push(`body: JSON.stringify(${JSON.stringify(item.bodySample)})`)
if (isForm) options.push('body: form')
const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : ''
const call = `const res = await fetch('${url}'${
options.length > 0 ? `, {\n ${options.join(',\n ')}\n})` : ')'
}`
return {
fetch: formSetup + call,
curl: curlParts.join(' \\\n'),
response: item.response,
}
}
export function authExamples(origin: string) {
const url = `${origin}${AUTH_EXAMPLE_PATH}`
return {
curl: `curl "${url}" \\\n -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}"`,
fetch: `const res = await fetch('${url}', {\n headers: { Authorization: 'Bearer ${API_KEY_PLACEHOLDER}' }\n})\nconst me = await res.json()`,
}
}
export function allEndpoints() {
return API_GROUPS.flatMap((group) => group.endpoints)
}
+66
View File
@@ -884,6 +884,72 @@ export const messages = {
'That key no longer exists.': 'Cette clé n’existe plus.',
'Manage API keys': 'Gérer les clés d’API',
'Loading API keys': 'Chargement des clés d’API',
Required: 'Obligatoire',
Rule: 'Règle',
Yes: 'Oui',
No: 'Non',
'Request body · multipart/form-data, 1 file field':
'Corps de la requête · multipart/form-data, 1 champ fichier',
'Request body · JSON': 'Corps de la requête · JSON',
'Request body': 'Corps de la requête',
'API key OK': 'Clé d’API acceptée',
'Query parameters': 'Paramètres d’URL',
'No query parameters.': 'Aucun paramètre d’URL.',
'No request body.': 'Aucun corps de requête.',
Errors: 'Erreurs',
'On this page': 'Sur cette page',
'Jump to section': 'Aller à la section',
'Jump to': 'Aller à',
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."':
'Ces routes sont prévues pour une session de navigateur connectée. Avec une clé d’API, verify, DELETE /auth/register, /api-key/* et /system/* répondent 403 « This action needs a real session. »',
'Session only': 'Session uniquement',
'Public auth flow': 'Parcours d’authentification public',
Public: 'Public',
'Verified user': 'Utilisateur vérifié',
'Owner only': 'Propriétaire uniquement',
Authentication: 'Authentification',
Notes: 'Notes',
'Users and invites': 'Utilisateurs et invitations',
'System · owner only': 'Système · propriétaire uniquement',
'Sign in and account': 'Connexion et compte',
'API key management': 'Gestion des clés d’API',
'Bad request': 'Requête invalide',
Unauthorized: 'Non autorisé',
Forbidden: 'Interdit',
Conflict: 'Conflit',
'Server error': 'Erreur serveur',
'This route is rate limited. Too many requests answer 429.':
'Cette route est limitée en fréquence. Trop de requêtes reçoivent la réponse 429.',
Message: 'Message',
'{0} route': '{0} route',
'{0} routes': '{0} routes',
'{0} routes, made for a browser session': '{0} routes, prévues pour une session de navigateur',
'API reference': 'Référence de l’API',
'Everything a developer needs to build on 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 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.',
'No key': 'Sans clé',
'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.',
'API key': 'Clé d’API',
'Full access': 'Accès complet',
"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.":
'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.':
'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.':
'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é.',
'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`.',
'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.':
'Chaque erreur est un corps en texte brut d’une seule phrase. Le code d’état est dans la ligne d’état HTTP, et le Content-Type est text/plain.',
'Error response · text/plain': 'Réponse d’erreur · text/plain',
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.':
'Les images dans `body` utilisent des chemins comme `/static/uploadedImages/<uuid>.png`. Ajoutez l’URL de base devant avant l’affichage.',
'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.':
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
Meaning: 'Signification',
} as const
export type MessageKey = keyof typeof messages
+4 -1
View File
@@ -391,9 +391,12 @@ describe('Hardener localization outcomes', () => {
document.body.textContent?.includes('/v1/article/'),
document.body.textContent?.includes('GET'),
]).toEqual([window.location.origin, true, true, true])
document.body
.querySelector<HTMLButtonElement>('button[aria-label^="GET /v1/article/. "]')!
.click()
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/?format=html')`,
`const res = await fetch('${window.location.origin}/v1/article/')`,
),
)
vi.unstubAllGlobals()
+28
View File
@@ -0,0 +1,28 @@
import type { HttpMethod } from '@/data/apiCatalogue'
const CHIP_BASE = 'h-5 gap-1 rounded-none px-1.5 py-0 text-xs whitespace-nowrap ring-0 border'
export const CHIP_CLASS = {
neutral: `${CHIP_BASE} border-accented bg-transparent font-normal text-toned`,
strong: `${CHIP_BASE} border-accented bg-elevated font-medium text-highlighted`,
success: `${CHIP_BASE} border-transparent bg-(--rc-success-soft) font-medium text-success`,
dashed: `${CHIP_BASE} border-dashed border-accented bg-transparent font-normal text-muted`,
}
const METHOD_BASE =
'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border'
export const METHOD_CLASS: Record<HttpMethod, string> = {
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
PATCH: `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`,
DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`,
}
export function splitInlineCode(text: string) {
return text.split('`').map((value, index) => ({ value, code: index % 2 === 1 }))
}
export function endpointAnchor(id: string) {
return `ep-${id}`
}
+383 -117
View File
@@ -1,164 +1,430 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, translate } from '@/i18n'
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import {
API_GROUPS,
ERROR_ROWS,
ERROR_SAMPLE,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
authExamples,
} from '@/data/apiCatalogue'
const SPY_OFFSET_PX = 120
const origin = computed(() => window.location.origin)
const { copied, copy } = useCopyToClipboard()
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyButtonIcon = computed(() =>
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
)
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
function copyBaseUrl() {
copy(origin.value)
}
const sampleArticle = {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>Every bathroom is different, but the way we price them isn’t.</p>',
images: [
const root = ref<HTMLElement | null>(null)
const activeSection = ref('auth')
const authTab = ref('curl')
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
])
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
{ label: 'curl', value: 'curl', code: examples.curl, lang: 'shell', title: 'Shell' },
{
id: 1,
path: '/static/uploadedImages/2f6a1c0e-2b8b-4c3d-9c1e-1a2b3c4d5e6f.png',
article_id: 12,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-03-12T09:14:00Z',
label: 'fetch',
value: 'fetch',
code: examples.fetch,
lang: 'javascript',
title: 'JavaScript',
},
],
body_format: 'html',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
}
]
})
const sampleArticleList = [sampleArticle]
const endpoints = computed(() => [
{
method: 'GET' as const,
path: '/v1/article/',
get description() {
return t('Lists every online article.')
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/?format=html`,
response: JSON.stringify(sampleArticleList, null, 2),
},
},
{
method: 'GET' as const,
path: '/v1/article/{id}',
get description() {
return t(
'Returns one online article. Drafts and unknown ids both return 404.',
)
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12?format=html`,
response: JSON.stringify(sampleArticle, null, 2),
},
},
const authNotes = computed(() => [
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.',
),
t(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
),
])
const notes = computed(() => [
t(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
),
t(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.',
),
t(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
),
t(
'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.',
),
])
const errorColumns = computed(() => [
{
accessorKey: 'status',
get header() {
return t('Status')
},
meta: { class: { th: 'w-17 lg:w-19', td: 'w-17 lg:w-19' } },
},
{
accessorKey: 'meaning',
get header() {
return t('Meaning')
},
meta: { class: { th: 'lg:w-53', td: 'lg:w-53' } },
},
{
accessorKey: 'messages',
get header() {
return t('Message')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
function quoteMessages(messages: string[]) {
return messages.map((message) => `"${message}"`).join(' / ')
}
function routeCount(count: number) {
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
}
function scrollParent(element: HTMLElement) {
let current = element.parentElement
while (current) {
const overflow = getComputedStyle(current).overflowY
if (overflow === 'auto' || overflow === 'scroll') {
return current
}
current = current.parentElement
}
return null
}
function sectionElement(id: string) {
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
}
function goTo(id: string) {
activeSection.value = id
sectionElement(id)?.scrollIntoView?.({ block: 'start' })
}
function updateActive() {
const element = root.value
const parent = element ? scrollParent(element) : null
if (!parent) {
return
}
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
let current = sections.value[0]!.id
for (const section of sections.value) {
const target = sectionElement(section.id)
if (target && target.getBoundingClientRect().top <= limit) {
current = section.id
}
}
activeSection.value = current
}
let scroller: HTMLElement | null = null
onMounted(() => {
scroller = root.value ? scrollParent(root.value) : null
scroller?.addEventListener('scroll', updateActive, { passive: true })
})
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
const SECTION_TITLE = 'text-lg font-semibold text-highlighted'
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</script>
<template>
<DashboardShell :mobile-title="t('Integration')">
<div class="flex max-w-199 flex-col gap-6">
<div class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Connect your website') }}
</h1>
<p class="text-muted">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex flex-col gap-1.5">
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted lg:text-2xl">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
{{ t('Everything a developer needs to build on RenewCMS.') }}
</p>
</div>
<p class="max-w-180 text-base text-pretty text-toned">
{{
t(
'A guide for the developer who builds your public site.',
'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>
</div>
<p class="text-base text-pretty text-toned">
{{
t(
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.",
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{
t('Base URL')
}}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
</div>
</div>
</div>
<EndpointCard
v-for="endpoint in endpoints"
:key="endpoint.path"
:method="endpoint.method"
:path="endpoint.path"
:description="endpoint.description"
:examples="endpoint.examples"
/>
<section
data-anchor="auth"
aria-labelledby="h-auth"
:class="[SECTION_CLASS, 'gap-3.5']"
>
<h2 id="h-auth" :class="SECTION_TITLE">{{ t('Authentication') }}</h2>
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<div :class="AUTH_CARD">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('Public') }}</span>
<UBadge
:label="t('No key')"
color="neutral"
variant="outline"
class="h-5 rounded-none border border-accented px-1.5 py-0 text-xs font-normal text-toned ring-0"
/>
</div>
<span class="text-pretty text-toned">{{
t(
'No key needed. Two read-only routes, and only online articles are returned.',
)
}}</span>
<div
class="flex flex-col gap-1 font-mono text-xs font-medium text-highlighted"
>
<span>GET /v1/article/</span>
<span>GET /v1/article/{id}</span>
</div>
</div>
<div class="flex flex-col gap-2.5">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon
name="i-lucide-info"
class="mt-0.75 size-4 shrink-0 text-info"
/>
<span class="text-base text-pretty text-toned">{{
note
<div :class="[AUTH_CARD, 'min-w-0']">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('API key') }}</span>
<UBadge
:label="t('Full access')"
color="success"
variant="soft"
class="h-5 rounded-none bg-(--rc-success-soft) px-1.5 py-0 text-xs font-medium text-success"
/>
</div>
<span class="text-pretty text-toned"
><CodeText
:text="
t(
'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.',
)
"
/></span>
<UTabs
v-model="authTab"
:items="authItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
:label="(item as (typeof authItems)[number]).title"
:lang="(item as (typeof authItems)[number]).lang"
:code="(item as (typeof authItems)[number]).code"
/>
</template>
</UTabs>
<span class="text-pretty text-muted"
><CodeText
:text="
t(
'Keys start with `rcms_` and are shown in full once, when you create them.',
)
"
/></span>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="min-h-11 self-start px-0 lg:min-h-7"
>
{{ t('Manage API keys') }}
</UButton>
</div>
</div>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in authNotes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
<section
v-for="group in API_GROUPS"
:key="group.key"
:data-anchor="group.key"
:aria-labelledby="`h-${group.key}`"
:class="SECTION_CLASS"
>
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 :id="`h-${group.key}`" :class="SECTION_TITLE">
{{ translate(group.title) }}
</h2>
<span class="text-muted">{{ routeCount(group.endpoints.length) }}</span>
</div>
<span class="text-pretty text-toned">{{ group.description }}</span>
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in group.endpoints"
:key="endpoint.id"
:endpoint="endpoint"
:origin="origin"
/>
</div>
</section>
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
<span class="text-muted">{{
t('{0} routes, made for a browser session', [sessionRouteCount])
}}</span>
</div>
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
</section>
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
<h2 id="h-errors" :class="SECTION_TITLE">{{ t('Errors') }}</h2>
<span class="text-pretty text-toned">{{
t(
'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>
<UTable
:data="ERROR_ROWS"
:columns="errorColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-3 py-2 text-sm font-medium text-muted',
td: 'px-3 py-2.5 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #status-cell="{ row }">
<span class="font-mono text-sm font-medium text-highlighted">{{
row.original.status
}}</span>
</template>
<template #meaning-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-highlighted">{{
translate(row.original.meaning)
}}</span>
<div class="flex flex-col gap-0.5 lg:hidden">
<span class="text-pretty text-toned">{{
quoteMessages(row.original.messages)
}}</span>
</div>
</div>
</template>
<template #messages-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty">{{
quoteMessages(row.original.messages)
}}</span>
</div>
</template>
</UTable>
<div class="min-w-0 border border-default bg-elevated">
<div
class="flex h-8 items-center border-b border-default px-3 font-mono text-xs font-medium text-muted"
>
{{ t('Error response · text/plain') }}
</div>
<pre
tabindex="0"
class="overflow-x-auto p-3 font-mono text-code whitespace-pre text-default"
>{{ ERROR_SAMPLE }}</pre
>
</div>
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
<h2 id="h-notes" :class="SECTION_TITLE">{{ t('Notes') }}</h2>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
</div>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="self-start px-0"
>
{{ t('Manage API keys') }}
</UButton>
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
</div>
</DashboardShell>
</template>
+488 -114
View File
@@ -1,16 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import UButton from '@nuxt/ui/components/Button.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import {
API_KEY_PLACEHOLDER,
ERROR_SAMPLE,
authExamples,
allEndpoints,
endpointExamples,
} from '@/data/apiCatalogue'
import IntegrationView from '../IntegrationView.vue'
const STUB_ORIGIN = 'https://cms.renew.example'
const SECTION_LABELS = [
'Authentication',
'Articles',
'Media',
'Users and invites',
'Settings',
'Session only',
'Errors',
'Notes',
]
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
function stubOrigin(origin: string) {
vi.stubGlobal('location', { ...window.location, origin })
@@ -26,6 +44,9 @@ function stubClipboard() {
}
async function mountIntegration() {
stubOrigin(STUB_ORIGIN)
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
@@ -42,10 +63,56 @@ async function mountIntegration() {
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, name: string) {
function findButton(wrapper: VueWrapper, name: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === name)
}
function row(wrapper: VueWrapper, route: string) {
const found = wrapper
.findAll('button[aria-expanded]')
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))
if (!found) {
throw new Error(`no row for ${route}`)
}
return found
}
function card(wrapper: VueWrapper, route: string) {
const [method, path] = route.split(' ')
return wrapper
.findAllComponents(EndpointCard)
.find(
(item) =>
item.props('endpoint').method === method && item.props('endpoint').path === path,
)!
}
async function expand(wrapper: VueWrapper, route: string) {
await row(wrapper, route).trigger('click')
await flushPromises()
return card(wrapper, route)
}
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
function rowRoute(item: ReturnType<VueWrapper['get']>) {
const [method, path] = item.findAll('span.font-mono').map((span) => span.text())
return `${method} ${path}`
}
function section(wrapper: VueWrapper, id: string) {
return wrapper.get(`[data-anchor="${id}"]`)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
@@ -54,89 +121,154 @@ afterEach(async () => {
await router.push('/login')
})
describe("Default — the page loads with the app's own origin", () => {
it('renders the header, base URL, endpoint cards and notes with the stubbed origin', async () => {
stubOrigin(STUB_ORIGIN)
describe('Default, every card collapsed', () => {
it('renders the header, intro, base URL and collapsed cards', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Connect your website')
expect(wrapper.text()).toContain('A guide for the developer who builds your public site.')
expect(wrapper.text()).toContain('Integration')
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(
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors.",
'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.',
)
expect(wrapper.text()).toContain(STUB_ORIGIN)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const cardTexts = wrapper.findAllComponents(EndpointCard).map((c) => c.text())
expect(cardTexts).toHaveLength(2)
expect(cardTexts[0]).toContain('/v1/article/')
expect(cardTexts[0]).toContain('Lists every online article.')
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
expect(cardTexts[1]).toContain('/v1/article/{id}')
expect(cardTexts[1]).toContain(
'Returns one online article. Drafts and unknown ids both return 404.',
)
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
expect(fetchTabs.length).toBeGreaterThan(0)
fetchTabs.forEach((tab) => {
expect(tab.attributes('aria-selected')).toBe('true')
})
expect(wrapper.text()).toContain(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
)
expect(wrapper.text()).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(wrapper.text()).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
})
it('renders the breadcrumb Integration', async () => {
stubOrigin(STUB_ORIGIN)
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Integration')
})
})
describe('Manage API keys link', () => {
it('sits below the three notes as a link-variant button with a trailing arrow and navigates to the keys screen', async () => {
stubOrigin(STUB_ORIGIN)
const wrapper = await mountIntegration()
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(anchor.attributes('href')).toBe('/integration/api-keys')
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
const lastNote = wrapper
.findAll('span')
.find((span) => span.text().startsWith('Browsers can only call the API'))!
const rows = wrapper
.findAllComponents(EndpointCard)
.map((item) => item.get('button[aria-expanded]'))
expect(rows).toHaveLength(allEndpoints().length)
rows.forEach((button) => expect(button.attributes('aria-expanded')).toBe('false'))
expect(
lastNote.element.compareDocumentPosition(anchor.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
wrapper.findAllComponents(EndpointCard).flatMap((item) => item.findAll('[role="tab"]')),
).toHaveLength(0)
})
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
it('lists the sections in the sticky index and the Jump to select', async () => {
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
const jump = wrapper.get('button[aria-label="Jump to section"]')
expect(jump.text()).toContain('Jump to')
})
it('shows Owner only on the three invite rows and Verified user elsewhere', async () => {
const wrapper = await mountIntegration()
for (const route of [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]) {
expect(row(wrapper, route).text()).toContain('Owner only')
expect(row(wrapper, route).text()).not.toContain('Verified user')
}
expect(row(wrapper, 'GET /v1/auth/me').text()).toContain('Verified user')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('Public')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('API key OK')
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the 20 cards as 7, 6, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([7, 6, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('7 routes')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
it('makes no /v1 request while rendering or expanding cards', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'POST /v1/article/')
await expand(wrapper, 'GET /v1/media/')
expect(globalThis.fetch).toBe(fetchSpy)
expect(fetchSpy).not.toHaveBeenCalled()
})
})
describe('Copied — the Base URL copy button', () => {
describe('Group scrolled, one card expanded', () => {
it('reports aria-expanded and shows the tables, tabs, code and errors', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
expect(row(wrapper, 'POST /v1/article/').attributes('aria-expanded')).toBe('true')
const text = created.text()
expect(text.indexOf('Query parameters')).toBeGreaterThan(-1)
expect(text.indexOf('Query parameters')).toBeLessThan(text.indexOf('Request body'))
expect(text).toContain('format')
expect(text).toContain('markdown, html or json. Default markdown.')
expect(text).toContain('title')
expect(text).toContain("3 to 200 characters. Can't be changed later.")
expect(text).toContain('Up to 10 000 characters.')
const tabs = created.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(text).toContain('JavaScript')
expect(
created
.findAll('button')
.some((button) => button.attributes('aria-label') === 'Copy JavaScript example'),
).toBe(true)
expect(text).toContain('Errors')
const statuses = created
.findAll('[title]')
.map((item) => item.text())
.join(' ')
expect(statuses).toContain('400')
expect(statuses).toContain('401')
expect(text).toContain('Article title must be between 3 and 200 characters.')
expect(text).toContain('The format must be one of: markdown, html, json.')
})
it('shows "No query parameters." and "No request body." when a card has none', async () => {
const wrapper = await mountIntegration()
const published = await expand(wrapper, 'GET /v1/settings')
expect(published.text()).toContain('No query parameters.')
expect(published.text()).toContain('No request body.')
})
it('shows the six media query parameters on the media list', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
expect(list.text()).toContain(name)
}
expect(list.text()).toContain('1 to 100. Default 24.')
})
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
const wrapper = await mountIntegration()
const upload = await expand(wrapper, 'POST /v1/media/')
expect(upload.text()).toContain('Request body · multipart/form-data, 1 file field')
expect(upload.text()).toContain('file')
expect(upload.text()).toContain('the image could not be read')
expect(upload.text()).toContain('the file exceeds the maximum upload size')
expect(upload.text()).toContain('the file must be a PNG, JPEG, WEBP, or SVG image')
const invite = await expand(wrapper, 'POST /v1/user/invite')
expect(invite.text()).toContain('Only the owner can do this.')
expect(invite.text()).toContain('This route is rate limited. Too many requests answer 429.')
expect(invite.text()).toContain("There's already a pending invite for this address.")
})
})
describe('Copied, the Base URL copy button', () => {
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
stubOrigin(STUB_ORIGIN)
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const copyButton = findButton(wrapper, 'Copy')!
await copyButton.trigger('click')
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
@@ -150,66 +282,308 @@ describe('Copied — the Base URL copy button', () => {
})
})
describe("Each EndpointCard's own Copy button is independent of the Base URL's", () => {
it("copies only that card's code and leaves the other buttons unaffected", async () => {
stubOrigin(STUB_ORIGIN)
describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", () => {
it("copies only that card's exact code and leaves the other buttons unaffected", async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
const cards = wrapper.findAllComponents(EndpointCard)
const firstCardCopyButton = cards[0]
const button = created
.findAll('button')
.find((button) => button.text().trim() === 'Copy')!
await firstCardCopyButton.trigger('click')
.find((item) => item.attributes('aria-label') === 'Copy JavaScript example')!
await button.trigger('click')
await flushPromises()
const expectedCode =
`const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
'const articles = await res.json()'
expect(writeText).toHaveBeenCalledWith(expectedCode)
const expected = endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch
expect(writeText).toHaveBeenCalledWith(expected)
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
expect(copied).toHaveLength(1)
expect(copied[0]!.attributes('aria-label')).toBe('Copy JavaScript example')
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const copyButtons = wrapper.findAll('button').filter((button) => {
const text = button.text().trim()
return text === 'Copy' || text === 'Copied'
})
const copiedButtons = copyButtons.filter((button) => button.text().trim() === 'Copied')
expect(copiedButtons).toHaveLength(1)
const baseUrlCopyButton = findButton(wrapper, 'Copy')
expect(baseUrlCopyButton).toBeTruthy()
const secondCardCopyButton = cards[1]
.findAll('button')
.find((button) => button.text().trim() === 'Copy')
expect(secondCardCopyButton).toBeTruthy()
await vi.advanceTimersByTimeAsync(2000)
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
describe('Authentication', () => {
it('renders the Public and API key cards, the tabs for GET /v1/auth/me, the link and both notes', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
describe('curl and Response tabs render, unchecked visually', () => {
it('shows the curl command and the Response JSON sample built from the real Article fields', async () => {
stubOrigin(STUB_ORIGIN)
expect(auth.text()).toContain(
'No key needed. Two read-only routes, and only online articles are returned.',
)
expect(auth.text()).toContain('GET /v1/article/')
expect(auth.text()).toContain('GET /v1/article/{id}')
expect(auth.text()).toContain(
"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.",
)
expect(auth.text()).toContain(
'Keys start with rcms_ and are shown in full once, when you create them.',
)
const tabs = auth.findAll('[role="tab"]').map((tab) => tab.text())
expect(tabs).toEqual(['curl', 'fetch'])
const examples = authExamples(STUB_ORIGIN)
expect(auth.text()).toContain(`${STUB_ORIGIN}/v1/auth/me`)
expect(auth.text()).toContain(API_KEY_PLACEHOLDER)
await activateTab(auth.element as HTMLElement, 'fetch')
expect(auth.text()).toContain(`fetch('${STUB_ORIGIN}/v1/auth/me'`)
expect(examples.curl).toContain('Authorization: Bearer rcms_')
const link = auth.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(link.attributes('href')).toBe('/integration/api-keys')
expect(auth.text()).toContain(
'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.',
)
expect(auth.text()).toContain(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a Retry-After header.',
)
})
it('navigates the Manage API keys link to the keys screen', async () => {
const wrapper = await mountIntegration()
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
await activateTab(firstCard.element as HTMLElement, 'curl')
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
})
})
await activateTab(firstCard.element as HTMLElement, 'Response')
describe('Session only', () => {
it('renders the banner, 13 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(firstCard.text()).toContain('"is_online"')
expect(firstCard.text()).toContain('"created_at"')
expect(firstCard.text()).toContain('"id"')
expect(session.text()).toContain('13 routes')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(13)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
const chipRows = rows.filter((item) => item.text().includes('Session only'))
expect(chipRows.map(rowRoute)).toEqual([
'GET /v1/system/update',
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
chipRows.forEach((item) =>
expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true),
)
})
it('captions Log in, Log out, Register and Reset password without a 403 claim', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-session-row]')
for (const path of [
'/v1/auth/login',
'/v1/auth/logout',
'/v1/auth/register',
'/v1/auth/password-reset',
]) {
const item = rows.find(
(candidate) =>
candidate.text().includes(path) &&
candidate.text().includes('Made for the browser.'),
)!
expect(item.text()).toContain('A key is ignored here, with no 403.')
expect(item.text()).not.toContain('Session only')
}
})
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(session.text()).toContain('System · owner only')
expect(session.text()).toContain('Sign in and account')
expect(session.text()).toContain('API key management')
const rowLabels = wrapper
.findAll('button[aria-expanded]')
.map((button) => button.attributes('aria-label'))
expect(rowLabels.some((label) => label?.includes('/v1/system'))).toBe(false)
})
it('lists the 6 Public auth flow rows with the Public chip and no lock', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-public-row]')
expect(rows).toHaveLength(6)
expect(rows.map((item) => item.text())).toEqual([
expect.stringContaining('GET/v1/Hello world.'),
expect.stringContaining('GET/v1/auth/registrationWhether registration is open.'),
expect.stringContaining(
'POST/v1/auth/password-reset/confirmSet a new password with the emailed code.',
),
expect.stringContaining('GET/v1/auth/inviteCheck an invite code.'),
expect.stringContaining('POST/v1/auth/invite/acceptAccept an invite.'),
expect.stringContaining('GET/healthzPlain text "ok". Outside /v1.'),
])
rows.forEach((item) => {
expect(item.text()).toContain('Public')
expect(item.text()).not.toContain('Session only')
expect(item.find('[class*="lucide-lock"]').exists()).toBe(false)
})
})
})
describe('Errors', () => {
it('renders the intro, the real messages and the 403 sample', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.text()).toContain(
'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.',
)
const rows = errors.findAll('tbody tr')
expect(rows.map((item) => item.find('td').text())).toEqual([
'400',
'401',
'403',
'404',
'409',
'429',
'500',
])
const text = errors.text()
for (const message of [
'The request cannot be processed due to a mismatch in the format of the body.',
'The expiry must be a date in the future.',
'You must be logged in to access this resource.',
'This action needs a real session.',
'Only the owner can do this.',
'You must verify your account to access this resource.',
'Article not found',
'Image not found',
'Invite not found',
'API key not found',
"There's already a pending invite for this address.",
'This invite has already been accepted.',
'Too many requests. Please try again later.',
'Internal server error',
]) {
expect(text).toContain(message)
}
const sample = errors.get('pre').text()
expect(sample).toBe(ERROR_SAMPLE)
expect(sample.split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
})
describe('Navigation', () => {
it('scrolls to the anchor and marks the index item current', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
const media = index.findAll('button').find((button) => button.text() === 'Media')!
expect(media.attributes('aria-current')).toBeUndefined()
await media.trigger('click')
await flushPromises()
expect(media.attributes('aria-current')).toBe('true')
expect(scrollIntoView).toHaveBeenCalledTimes(1)
expect(scrollIntoView.mock.contexts[0]).toBe(section(wrapper, 'media').element)
const current = index.findAll('button[aria-current="true"]')
expect(current.map((button) => button.text())).toEqual(['Media'])
})
it('lists the same sections in the mobile Jump to select', async () => {
const wrapper = await mountIntegration()
const jump = wrapper.get('button[aria-label="Jump to section"]')
await jump.trigger('click')
await flushPromises()
const options = Array.from(document.body.querySelectorAll('[role="option"]')).map(
(option) => option.textContent?.trim(),
)
expect(options).toEqual(SECTION_LABELS)
})
})
describe('Tabs, curl and Response render, unchecked visually', () => {
it('shows curl with the origin and the Authorization header on a non-public route', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
await activateTab(created.element as HTMLElement, 'curl')
expect(created.text()).toContain(`curl -X POST "${STUB_ORIGIN}/v1/article/"`)
expect(created.text()).toContain(`Authorization: Bearer ${API_KEY_PLACEHOLDER}`)
})
it('shows curl without an Authorization header on a public route', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/article/')
await activateTab(list.element as HTMLElement, 'curl')
expect(list.text()).toContain(`curl "${STUB_ORIGIN}/v1/article/"`)
expect(list.text()).not.toContain('Authorization')
})
it('shows the Response sample built from the real article fields', async () => {
const wrapper = await mountIntegration()
const single = await expand(wrapper, 'GET /v1/article/{id}')
await activateTab(single.element as HTMLElement, 'Response')
for (const key of [
'"id"',
'"title"',
'"body"',
'"body_format"',
'"is_online"',
'"created_at"',
'"updated_at"',
]) {
expect(single.text()).toContain(key)
}
expect(single.text()).not.toContain('"images"')
expect(single.text()).not.toContain('"status"')
})
})
describe('Notes', () => {
it('renders the format, image-prefix and CORS notes', async () => {
const wrapper = await mountIntegration()
const notes = section(wrapper, 'notes').text()
expect(notes).toContain(
'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.',
)
expect(notes).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(notes).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
})
})
+93 -11
View File
@@ -823,6 +823,43 @@ async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy', exact: true }).first().click()
}
function integrationState(overrides: Partial<VisualState> = {}): VisualState {
return {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
...overrides,
}
}
async function scrollToAnchor(
page: Page,
anchor: string,
offsets?: { desktop?: number; mobile?: number },
) {
const mobile = (page.viewportSize()?.width ?? 0) < 1024
const offset = mobile ? offsets?.mobile : offsets?.desktop
await page.evaluate(
({ id, offset }) => {
const target = document.querySelector(`[data-anchor="${id}"]`)
if (!target) return
if (offset === undefined) {
target.scrollIntoView({ block: 'start' })
return
}
let scroller = target.parentElement
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
if (!scroller) return
scroller.scrollTop +=
target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset
},
{ id: anchor, offset },
)
await page.waitForTimeout(150)
}
const acceptInvitePath = '/invite/accept?c=abc123'
const acceptInviteEmail = 'lea.martin@renew.example'
@@ -2472,17 +2509,62 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'integration-api-keys': apiKeysStates,
integration: {
Default: {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
},
Copied: {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
steps: clickBaseUrlCopy,
},
'1. Default': integrationState(),
'2. Group scrolled, one card expanded': integrationState({
steps: async (page) => {
await page.getByRole('button', { name: /^POST \/v1\/media\/\. / }).click()
await page
.locator('[data-anchor="ep-med-up"]')
.getByRole('tab', { name: 'curl', exact: true })
.click()
await scrollToAnchor(page, 'ep-med-up', { desktop: 126, mobile: 83 })
},
exception: {
maxPercent: 37.5,
reason: 'The new Query parameters block adds about 70 px above Request body, which shifts every row below the card, and the captions use the real server strings instead of the board samples',
approvedBy: 'Florian Sylvain',
},
}),
'3a. Copied: base URL': integrationState({ steps: clickBaseUrlCopy }),
'3b. Copied: CodeBlock': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'auth')
await page.getByRole('button', { name: 'Copy Shell example' }).click()
await page.mouse.move(0, 0)
},
}),
'4. Authentication': integrationState({
steps: (page) => scrollToAnchor(page, 'auth'),
}),
'5. Session only': integrationState({
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: 55 }),
exception: {
maxPercent: 12,
reason: 'The banner names the real routes and wraps differently, a 13th row (DELETE /v1/auth/register) and the login-family captions are new, and a Public auth flow block follows',
approvedBy: 'Florian Sylvain',
},
}),
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 48, mobile: 47 }),
exception: {
maxPercent: 12.5,
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board scroll position varies between captures',
approvedBy: 'Florian Sylvain',
},
}),
'7. Navigation': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'media', { desktop: 14 })
if ((page.viewportSize()?.width ?? 0) < 1024) {
await page.getByRole('button', { name: 'Jump to section' }).click()
}
},
exception: {
maxPercent: 12.5,
reason: 'The board frame scroll position varies by 18px between captures, so the desktop diff flickers between about 8 % and 12 %',
approvedBy: 'Florian Sylvain',
},
}),
},
articles: {
Default: {