Files
RenewCMS/web/src/data/apiCatalogue.ts
T

1530 lines
38 KiB
TypeScript

export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | '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
status: number
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[]
note?: 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 OWNER_OR_ADMIN_MESSAGE = 'Only owners and admins 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 PURGE_INTERVAL = 'Enter a whole number of days between 1 and 365.'
const PURGE_CONFLICT =
'The library changed, so nothing was purged. Check the new summary and try again.'
const PURGE_BUSY = 'A purge is already running.'
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 PAGE_QUERY: ApiField = {
name: 'page',
type: 'number',
required: false,
rule: 'A whole number from 1. Default 1.',
}
const SIZE_QUERY: ApiField = {
name: 'size',
type: 'number',
required: false,
rule: '5, 25, 50 or 100. Default 25.',
}
const ARTICLE_LIST_QUERY: ApiField[] = [
FORMAT_QUERY,
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched anywhere in the title, ignoring case and accents.',
},
{
name: 'status',
type: 'string',
required: false,
rule: 'online or draft. Default both.',
},
{
name: 'sort',
type: 'string',
required: false,
rule: 'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default updated_at:desc.',
},
{
name: 'site',
type: 'string',
required: false,
rule: 'The key of a site. Only its articles are listed. An unknown key answers an empty page.',
},
{
name: 'seo',
type: 'string',
required: false,
rule: 'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.',
},
]
const SITEMAP_QUERY: ApiField[] = [
{
name: 'page',
type: 'number',
required: false,
rule: 'A whole number from 1. Default 1.',
},
{
name: 'size',
type: 'number',
required: false,
rule: '25, 50, 100 or 500. Default 100.',
},
]
const CHECK_BODY: ApiField[] = [
{
name: 'body',
type: 'string',
required: false,
rule: 'Replaces the saved body for this check only. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Same rules as PATCH. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image. null means no cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image. null means no social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.',
},
]
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: 'filter',
type: 'string',
required: false,
rule: 'all, unused or duplicates. Default all.',
},
{
name: 'sort',
type: 'string',
required: false,
rule: 'newest, oldest, name, largest or total_size. Default newest.',
},
{
name: 'variants',
type: 'string',
required: false,
rule: 'with or without. Default both.',
},
PAGE_QUERY,
SIZE_QUERY,
]
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,
site_id: 1,
slug: 'how-we-price-a-bathroom-renovation',
excerpt: 'What drives the price of a bathroom renovation.',
seo_title: '',
seo_description: '',
cover_image_id: 4,
cover_image_alt: 'A tiled bathroom wall',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
seo_dismissed: [],
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
})
const SAMPLE_SITE_KEY = 'renew-blog'
const resolvedSeo = () => ({
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
canonical_url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
robots: 'index, follow',
og: {
type: 'article',
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
image_alt: 'A tiled bathroom wall',
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
locale: 'en',
},
twitter: {
card: 'summary_large_image',
title: `${ARTICLE_TITLE} | Renew`,
description: 'What drives the price of a bathroom renovation.',
image: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
image_alt: 'A tiled bathroom wall',
},
image: {
url: 'https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg',
alt: 'A tiled bathroom wall',
source: 'cover',
},
authors: [
{
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
same_as: ['https://github.com/ines-duarte'],
},
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-09-10T07:00:00Z',
fallbacks: { title: 'title', description: 'excerpt', image: 'cover', canonical: 'site' },
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: ARTICLE_TITLE,
description: 'What drives the price of a bathroom renovation.',
image: ['https://blog.renew.example/static/uploadedImages/3f9c2b7e.jpg'],
author: [
{
'@type': 'Person',
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
sameAs: ['https://github.com/ines-duarte'],
},
],
datePublished: '2026-03-12T09:14:00Z',
dateModified: '2026-09-10T07:00:00Z',
mainEntityOfPage: {
'@type': 'WebPage',
'@id': 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
},
},
})
const seoCheck = () => ({
status: 'suggestions',
suggestions: [
{ code: 'desc_short', field: 'seo_description', params: { length: 49, min: 70 } },
{ code: 'no_au', field: 'author_ids', params: {} },
],
slug: { available: false, suggestion: 'how-we-price-a-bathroom-renovation-2' },
})
const sitemapPage = () => [
{
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
modified_at: '2026-09-10T07:00:00Z',
noindex: false,
},
{
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
modified_at: '2026-09-09T07:00:00Z',
noindex: true,
},
]
const LIST_ITEM_OMITTED_KEYS = [
'seo_title',
'seo_description',
'cover_image_id',
'cover_image_alt',
'social_title',
'social_description',
'social_image_id',
'canonical_url',
'noindex',
'seo_dismissed',
]
const articleListItem = (overrides: Record<string, unknown> = {}) => ({
...Object.fromEntries(
Object.entries(article(overrides)).filter(([key]) => !LIST_ITEM_OMITTED_KEYS.includes(key)),
),
seo_status: 'suggestions',
seo_suggestions: 2,
})
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 site = (overrides: Record<string, unknown> = {}) => ({
id: 1,
name: 'Renew blog',
key: 'renew-blog',
base_url: 'https://blog.renew.example',
default_social_image_id: 4,
default_robots: 'index',
locale: 'en',
is_default: true,
article_count: 30,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: 'Notes from a renovation site.',
...overrides,
})
const author = (overrides: Record<string, unknown> = {}) => ({
id: 2,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter.',
avatar_id: null,
website: 'https://ines-duarte.example',
social_links: [],
user_id: 3,
is_linked: true,
article_count: 9,
...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 ownerOrAdminErrors = (...own: ApiError[]) => [
...own,
e(401, UNAUTHORIZED),
e(403, OWNER_OR_ADMIN_MESSAGE),
]
const articlePage = () => ({
items: [
articleListItem(),
articleListItem({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
slug: 'choosing-tiles-for-a-small-bathroom',
excerpt: '',
updated_at: '2026-09-09T07:00:00Z',
seo_status: 'ready',
seo_suggestions: 0,
}),
],
total: 30,
page: 1,
size: 25,
counts: { all: 30, online: 28, draft: 2, suggestions: 3 },
})
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = []
const PURGE_SETTINGS = {
auto_purge_enabled: true,
auto_purge_interval_days: 30,
last_purge_at: '2026-09-10T07:00:00Z',
last_purge_merged: 2,
last_purge_articles_updated: 5,
enabled_at: '2026-08-11T07:00:00Z',
next_purge_at: '2026-10-09T07:00:00Z',
}
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,
status: response === NO_CONTENT_RESPONSE ? 204 : 200,
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 one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). Anonymous callers get online articles only.',
'Public',
articleErrors(e(400, BODY_MISMATCH)),
json(articlePage()),
{ query: ARTICLE_LIST_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.',
},
status: 201,
},
),
endpoint(
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
...articleIdErrors,
e(404, 'site_not_found'),
e(
409,
'slug_taken (the X-Slug-Suggestion header holds the first free slug)',
),
e(422, 'invalid_url'),
),
),
json(article({ body: 'Updated text.', is_online: false })),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'body',
type: 'string',
required: false,
rule: 'Up to 10 000 characters. An empty string empties the body; null is refused.',
},
{
name: 'site_id',
type: 'number',
required: false,
rule: 'The id of an existing site, 1 or more. null is refused.',
},
{
name: 'slug',
type: 'string',
required: false,
rule: 'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.',
},
{
name: 'excerpt',
type: 'string',
required: false,
rule: 'Up to 300 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'seo_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'cover_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the cover.',
},
{
name: 'cover_image_alt',
type: 'string',
required: false,
rule: 'Up to 200 characters. An empty string clears it; null is refused.',
},
{
name: 'social_title',
type: 'string',
required: false,
rule: 'Up to 120 characters. An empty string clears it; null is refused.',
},
{
name: 'social_description',
type: 'string',
required: false,
rule: 'Up to 320 characters. An empty string clears it; null is refused.',
},
{
name: 'social_image_id',
type: 'number | null',
required: false,
rule: 'The id of an existing image, 1 or more. null removes the social image.',
},
{
name: 'canonical_url',
type: 'string',
required: false,
rule: 'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.',
},
{
name: 'noindex',
type: 'boolean',
required: false,
rule: 'Only a flag: the article stays online. null is refused.',
},
{
name: 'seo_dismissed',
type: 'string[]',
required: false,
rule: 'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.',
},
],
bodySample: {
body: 'Updated text.',
site_id: 2,
slug: 'bathroom-pricing',
excerpt: 'What drives the price.',
cover_image_id: 4,
},
},
),
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] },
),
endpoint(
'art-check',
'POST',
'/v1/article/{id}/seo/check',
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.',
'Verified user',
verified([
e(400, BODY_MISMATCH),
e(400, 'invalid_slug'),
e(400, 'image_not_found'),
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Article not found'),
e(422, 'invalid_url'),
]),
json(seoCheck()),
{
bodyKind: 'json',
body: CHECK_BODY,
bodySample: { seo_title: 'Bathroom costs, explained', noindex: false },
},
),
],
},
{
key: 'seo',
title: 'Search and sharing',
description:
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.",
endpoints: [
endpoint(
'art-seo',
'GET',
'/v1/article/{id}/seo',
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.',
'Public',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
json(resolvedSeo()),
),
endpoint(
'site-sitemap',
'GET',
'/v1/site/{key}/sitemap',
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.',
'Public',
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
json(sitemapPage()),
{ query: SITEMAP_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()],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 0, duplicates: 0 },
}),
{ query: MEDIA_QUERY },
),
endpoint(
'med-get',
'GET',
'/v1/media/{id}',
'Get one image, with the same fields as an item of the list.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json(mediaItem()),
{ pathId: 4 },
),
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,
status: 201,
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, sites_updated: 1, 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, authors and sites that use an image, with the places where each one uses it.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([
{
type: 'article',
id: 12,
title: ARTICLE_TITLE,
published: true,
fields: ['body', 'cover'],
},
{ type: 'author', id: 2, title: 'Inès Duarte', fields: ['avatar'] },
{ type: 'site', id: 1, title: 'Renew blog', fields: ['default_social_image'] },
]),
{ pathId: 4 },
),
endpoint(
'med-purge-get',
'GET',
'/v1/media/purge-settings',
'Get the automatic duplicate purge settings.',
'Owner or admin',
ownerOrAdminErrors(),
json(PURGE_SETTINGS),
),
endpoint(
'med-purge-set',
'PATCH',
'/v1/media/purge-settings',
'Turn the automatic purge on or off, or change its interval.',
'Owner or admin',
ownerOrAdminErrors(e(400, BODY_MISMATCH), e(400, PURGE_INTERVAL)),
json(PURGE_SETTINGS),
{
bodyKind: 'json',
body: [
{
name: 'auto_purge_enabled',
type: 'boolean',
required: false,
rule: 'Leave out to keep the current value.',
},
{
name: 'auto_purge_interval_days',
type: 'number',
required: false,
rule: '1 to 365. Leave out to keep the current value.',
},
],
bodySample: {
auto_purge_enabled: true,
auto_purge_interval_days: 30,
},
},
),
endpoint(
'med-purge-prev',
'GET',
'/v1/media/purge-preview',
'Preview what a purge would merge. Nothing changes.',
'Owner or admin',
ownerOrAdminErrors(),
json({
groups: 1,
copies: 2,
articles: 5,
list: [
{
keep_id: 4,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg',
used_in: 5,
merged: 2,
},
],
more: 0,
fingerprint: '9c1f5e0a7d3b',
}),
),
endpoint(
'med-purge-run',
'POST',
'/v1/media/purge',
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.',
'Owner or admin',
ownerOrAdminErrors(
e(400, BODY_MISMATCH),
e(409, PURGE_CONFLICT),
e(409, PURGE_BUSY),
),
json({
groups_total: 1,
groups_purged: 1,
merged: 2,
articles_updated: 5,
sites_updated: 1,
failed_groups: 0,
}),
{
bodyKind: 'json',
body: [
{
name: 'fingerprint',
type: 'string',
required: false,
rule: 'The fingerprint of the preview. Without it, the purge runs on the current library.',
},
],
bodySample: { fingerprint: '9c1f5e0a7d3b' },
},
),
],
},
{
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()),
{
status: 201,
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, including the activity log retention.',
endpoints: [
endpoint(
'set-get',
'GET',
'/v1/settings',
'Get settings.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json({
version: 'v3.0.0-beta',
activity_retention_days: 90,
activity_retention_max_gb: 5,
activity_log_size_bytes: 3435973837,
activity_last_cleanup_at: '2026-09-10T03:00:00Z',
activity_last_cleanup_removed: 12,
activity_next_cleanup_at: '2026-09-11T03:00:00Z',
}),
),
],
},
{
key: 'sites',
title: 'Sites',
description:
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
endpoints: [
endpoint(
'site-list',
'GET',
'/v1/site/',
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.',
'Verified user',
verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]),
json([
site(),
site({
id: 2,
name: 'Renew shop',
key: 'renew-shop',
base_url: 'https://shop.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 4,
site_name: '',
title_template: '%title% - %site%',
default_description: '',
}),
]),
{
query: [
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.',
},
],
},
),
],
},
{
key: 'authors',
title: 'Authors',
description:
'The people credited on articles. Any verified user can read them; changes are made in the browser by an owner or admin.',
endpoints: [
endpoint(
'author-list',
'GET',
'/v1/author/',
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.',
'Verified user',
verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]),
json({
items: [
author(),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
website: null,
user_id: null,
is_linked: false,
article_count: 3,
}),
],
total: 2,
page: 1,
size: 25,
}),
{
query: [
PAGE_QUERY,
SIZE_QUERY,
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched in the name or the slug.',
},
],
},
),
],
},
]
const row = (
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: 'Sites · owner only',
rows: [
row('POST', '/v1/site/', 'Create a site'),
row('PATCH', '/v1/site/{id}', 'Edit a site'),
row('PATCH', '/v1/site/{id}/default', 'Make a site the default'),
row('DELETE', '/v1/site/{id}', 'Delete a site, moving its articles with reassign_to'),
],
},
{
title: 'Authors · owner and admin',
rows: [
row('POST', '/v1/author/', 'Create an author'),
row(
'PATCH',
'/v1/author/{id}',
'Edit an author, or your own author as a verified user',
),
row(
'DELETE',
'/v1/author/{id}',
'Delete an author, moving its articles with reassign_to',
),
row('POST', '/v1/author/me', 'Create your own author profile, as any verified user'),
],
},
{
title: 'Settings · owner only',
rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')],
},
{
title: 'Activity log',
rows: [
row('GET', '/v1/activity', 'List activity events'),
row('GET', '/v1/activity/summary', 'Activity summary, owner and admin'),
row('GET', '/v1/activity/export', 'Export activity as CSV, owner and admin'),
],
},
{
title: 'API key management',
rows: [
row('GET', '/v1/api-key', 'List keys'),
row('POST', '/v1/api-key', 'Create a key'),
row('DELETE', '/v1/api-key/{id}', 'Revoke a key'),
],
},
]
export const PUBLIC_AUTH_ROWS: PublicRow[] = [
{ 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],
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
},
{
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'>) {
const keyed = item.path.replace('{key}', SAMPLE_SITE_KEY)
return item.pathId === null ? keyed : keyed.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)
}