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

+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)
}