2150 lines
57 KiB
TypeScript
2150 lines
57 KiB
TypeScript
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
export type AccessLevel = 'Public' | 'Verified user' | 'Needs permission' | 'Owner only'
|
|
|
|
export interface ApiField {
|
|
name: string
|
|
type: string
|
|
required: boolean
|
|
rule: string
|
|
}
|
|
|
|
export interface ApiError {
|
|
status: number
|
|
caption: string
|
|
literal?: boolean
|
|
tail?: 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
|
|
prettyBody: boolean
|
|
searchKeys: string[]
|
|
}
|
|
|
|
export interface FieldTypeRow {
|
|
type: string
|
|
value: string
|
|
rules: string
|
|
}
|
|
|
|
export interface GroupSnippet {
|
|
key: string
|
|
label: string
|
|
code: string
|
|
}
|
|
|
|
export interface ApiGroup {
|
|
key: string
|
|
title: string
|
|
description: string
|
|
note?: string
|
|
fieldTypes?: FieldTypeRow[]
|
|
snippet?: GroupSnippet
|
|
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[]
|
|
codes?: 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 = 'missing_permission'
|
|
export const MISSING_PERMISSION_BODY = 'missing_permission'
|
|
export const IMAGES_AFTER_GROUP = 'sites'
|
|
export const FIELD_TYPES_ANCHOR = 'ct-fields'
|
|
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
|
|
export const 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 STORY_QUERY: ApiField[] = [
|
|
{
|
|
name: 'lang',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'A language tag such as fr or pt-BR. Default: the site default language.',
|
|
},
|
|
{
|
|
name: 'fallback',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'Only the value default: serve the published default-language version when the requested one is not published.',
|
|
},
|
|
FORMAT_QUERY,
|
|
]
|
|
|
|
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 SAMPLE_CONTENT_TYPE_KEY = 'events'
|
|
const SAMPLE_BUILTIN_TYPE_KEY = 'author'
|
|
|
|
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_GB',
|
|
locale_alternates: ['fr_FR'],
|
|
},
|
|
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: 'pattern',
|
|
site_text_skipped: false,
|
|
},
|
|
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',
|
|
},
|
|
},
|
|
lang: 'en',
|
|
alternates: [
|
|
{
|
|
id: 13,
|
|
lang: 'fr',
|
|
story_id: 5,
|
|
slug: 'comment-nous-chiffrons-une-renovation',
|
|
url: 'https://blog.renew.example/fr/comment-nous-chiffrons-une-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 PUBLIC_SAMPLE = {
|
|
lang: 'fr',
|
|
slug: 'comment-nous-chiffrons-une-renovation',
|
|
story: 5,
|
|
}
|
|
|
|
const alternates = () => [
|
|
{
|
|
id: 10,
|
|
lang: 'en',
|
|
story_id: 5,
|
|
slug: 'how-we-price-a-bathroom-renovation',
|
|
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
|
},
|
|
{
|
|
id: 11,
|
|
lang: 'de',
|
|
story_id: 5,
|
|
slug: 'so-kalkulieren-wir-eine-badsanierung',
|
|
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
|
},
|
|
]
|
|
|
|
const frenchVersion = (extra: Record<string, unknown> = {}) => ({
|
|
...article({
|
|
id: 12,
|
|
title: 'Comment nous chiffrons une rénovation',
|
|
slug: PUBLIC_SAMPLE.slug,
|
|
lang: 'fr',
|
|
story_id: 5,
|
|
}),
|
|
alternates: alternates(),
|
|
...extra,
|
|
})
|
|
|
|
const sitemapPage = () => [
|
|
{
|
|
url: 'https://blog.renew.example/how-we-price-a-bathroom-renovation',
|
|
modified_at: '2026-09-10T07:00:00Z',
|
|
noindex: false,
|
|
lang: 'en',
|
|
},
|
|
{
|
|
url: 'https://blog.renew.example/choosing-tiles-for-a-small-bathroom',
|
|
modified_at: '2026-09-09T07:00:00Z',
|
|
noindex: true,
|
|
lang: 'en',
|
|
},
|
|
]
|
|
|
|
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 fieldOf = (
|
|
key: string,
|
|
label: string,
|
|
type: string,
|
|
overrides: Record<string, unknown> = {},
|
|
) => ({
|
|
key,
|
|
label,
|
|
type,
|
|
required: false,
|
|
unique: false,
|
|
default: null,
|
|
min: null,
|
|
max: null,
|
|
options: null,
|
|
relation: null,
|
|
...overrides,
|
|
})
|
|
|
|
const eventFields = [
|
|
fieldOf('title', 'Title', 'text', { required: true, min: 3, max: 120 }),
|
|
fieldOf('starts_on', 'Starts on', 'date', { required: true }),
|
|
fieldOf('capacity', 'Capacity', 'number', { required: true, min: 1, max: 5000 }),
|
|
fieldOf('online', 'Online', 'boolean'),
|
|
fieldOf('category', 'Category', 'select', { options: ['Workshop', 'Talk', 'Open day'] }),
|
|
fieldOf('cover', 'Cover', 'media'),
|
|
]
|
|
|
|
const eventType = {
|
|
id: 2,
|
|
key: 'events',
|
|
name: 'Events',
|
|
icon: 'calendar',
|
|
fields: eventFields,
|
|
created_at: '2026-09-02T08:30:00Z',
|
|
updated_at: '2026-10-20T14:05:00Z',
|
|
}
|
|
|
|
const eventData = {
|
|
title: 'Open day',
|
|
starts_on: '2026-11-14',
|
|
capacity: 120,
|
|
online: false,
|
|
category: 'Open day',
|
|
cover: 4,
|
|
}
|
|
|
|
const eventEntry = (data: Record<string, unknown> = eventData) => ({
|
|
id: 31,
|
|
content_type: 'events',
|
|
data,
|
|
created_by: 1,
|
|
updated_by: 1,
|
|
created_at: '2026-10-20T14:05:00Z',
|
|
updated_at: '2026-10-22T09:41:00Z',
|
|
media: {
|
|
cover: {
|
|
id: 4,
|
|
filename: 'north-wall.jpg',
|
|
width: 1600,
|
|
height: 1067,
|
|
size: 3900000,
|
|
thumbnail_url: '/static/uploadedImages/3f9c-thumb.jpg',
|
|
},
|
|
},
|
|
relations: {},
|
|
})
|
|
|
|
const articleBuiltinFields = [
|
|
fieldOf('title', 'Title', 'text', { required: true, min: 3, max: 200 }),
|
|
fieldOf('slug', 'Slug', 'text', { required: true, unique: true }),
|
|
fieldOf('body', 'Body', 'richtext'),
|
|
]
|
|
|
|
const authorBuiltinFields = [
|
|
fieldOf('name', 'Name', 'text', { required: true, min: 1, max: 100 }),
|
|
fieldOf('slug', 'Slug', 'text', { required: true, unique: true }),
|
|
]
|
|
|
|
const FIELD_TYPE_ROWS: FieldTypeRow[] = [
|
|
{
|
|
type: 'text',
|
|
value: 'string',
|
|
rules: 'min/max = length in characters. Can be unique.',
|
|
},
|
|
{
|
|
type: 'richtext',
|
|
value: 'string (Markdown)',
|
|
rules: 'min/max = length. Cannot be unique.',
|
|
},
|
|
{ type: 'number', value: 'number', rules: 'min/max = value. Can be unique.' },
|
|
{ type: 'boolean', value: 'true/false', rules: 'No min/max. Cannot be unique.' },
|
|
{
|
|
type: 'date',
|
|
value: 'string "YYYY-MM-DD"',
|
|
rules: 'min/max = earliest/latest date. Can be unique.',
|
|
},
|
|
{
|
|
type: 'select',
|
|
value: 'one string from `options` (1 to 50 choices)',
|
|
rules: 'Can be unique.',
|
|
},
|
|
{
|
|
type: 'media',
|
|
value: 'the numeric id of an uploaded image (see Media)',
|
|
rules: '\u2014',
|
|
},
|
|
{
|
|
type: 'relation',
|
|
value: 'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many',
|
|
rules: '\u2014',
|
|
},
|
|
]
|
|
|
|
const NAME_RULE = 'Up to 100 characters.'
|
|
const FIELDS_RULE = '1 to 50 fields, in order. See Field types.'
|
|
const ENTRY_DATA_RULE =
|
|
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover."
|
|
const ENTRY_SEARCH_KEYS = ['title', 'starts_on', 'capacity', 'online', 'category', 'cover']
|
|
const INVALID_PAGE_SIZE_SORT = 'Invalid page, size or sort.'
|
|
|
|
const me = {
|
|
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',
|
|
}
|
|
|
|
const ownerRole = { id: 1, name: 'Owner', built_in: true, locked: true }
|
|
const writerRole = { id: 3, name: 'Writer', built_in: true, locked: false }
|
|
|
|
const meWithAccess = {
|
|
...me,
|
|
author: null,
|
|
access: {
|
|
role: ownerRole,
|
|
permissions: [
|
|
{ key: 'content.read', scope: 'all' },
|
|
{ key: 'content.create', scope: 'all' },
|
|
{ key: 'content.update', scope: 'all' },
|
|
{ key: 'content.delete', scope: 'all' },
|
|
{ key: 'content.publish', scope: 'all' },
|
|
{ key: 'settings.view', scope: 'all' },
|
|
{ key: 'users.view', scope: 'all' },
|
|
{ key: 'content.update:article', scope: 'own' },
|
|
],
|
|
},
|
|
}
|
|
|
|
const invite = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 7,
|
|
kind: 'invite',
|
|
email: 'lea.martin@renew.example',
|
|
role: writerRole,
|
|
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 literal = (status: number, caption: string, tail?: string): ApiError => ({
|
|
status,
|
|
caption,
|
|
literal: true,
|
|
...(tail ? { tail } : {}),
|
|
})
|
|
|
|
const e401 = e(401, UNAUTHORIZED)
|
|
const eMissingPermission = literal(403, MISSING_PERMISSION_BODY)
|
|
const contentTypeNotFound = literal(404, 'content_type_not_found')
|
|
const entryNotFound = literal(404, 'entry_not_found')
|
|
const invalidEntryUnique = literal(409, 'invalid_entry: title must be unique')
|
|
const invalidEntryRange = literal(422, 'invalid_entry: capacity must be at most 5000')
|
|
|
|
const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)]
|
|
|
|
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
|
|
|
|
const verifiedAfterLogin = (errors: ApiError[]) =>
|
|
errors.flatMap((error) => (error.status === 401 ? [error, e(403, VERIFY_REQUIRED)] : [error]))
|
|
|
|
const permissionErrors = (...own: ApiError[]) => [...own, e(401, UNAUTHORIZED), eMissingPermission]
|
|
|
|
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,
|
|
prettyBody: false,
|
|
searchKeys: [],
|
|
...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. A title is edited like any other field and never changes the slug.',
|
|
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-public-lookup',
|
|
'GET',
|
|
'/v1/public/site/{key}/lang/{lang}/article/{slug}',
|
|
'Exact lookup by site, language and slug. Returns the published version and its alternates, the other published versions of the article in enabled languages. It never falls back to another language, ignores any cookie or key, and never returns a draft.',
|
|
'Public',
|
|
[
|
|
e(400, 'invalid_language'),
|
|
e(400, FORMAT_INVALID),
|
|
e(404, 'article_not_found'),
|
|
e(404, 'site_not_found'),
|
|
],
|
|
json(frenchVersion()),
|
|
{ query: [FORMAT_QUERY] },
|
|
),
|
|
endpoint(
|
|
'art-public-story',
|
|
'GET',
|
|
'/v1/public/site/{key}/story/{story_id}',
|
|
'Resolve an article by its story_id in a language, with an explicit fallback to the default language only. The answer adds requested_lang and fallback to the version and its alternates. Without lang the site default language is requested.',
|
|
'Public',
|
|
[
|
|
e(400, 'invalid_language'),
|
|
e(400, BODY_MISMATCH),
|
|
e(400, FORMAT_INVALID),
|
|
e(400, ID_FORMAT),
|
|
e(404, 'language_not_available'),
|
|
e(404, 'site_not_found'),
|
|
],
|
|
json(frenchVersion({ requested_lang: 'fr', fallback: false })),
|
|
{ query: STORY_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, one per version in an enabled language, by id ascending, as an array of { url, modified_at, noindex, lang }. 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: 'ctypes',
|
|
title: 'Content types',
|
|
description:
|
|
'Define your own kind of content, like events or recipes. A user whose role can create and edit content types defines a type made of 1 to 50 ordered fields; the type then has its own entry routes.',
|
|
note: 'A content type has a `key` (2 to 40 lowercase letters, digits or hyphens, chosen at creation and never changed) and fields. Each field has a `key` (letters, digits and underscores, starting with a letter), a `label`, a `type` and rules.',
|
|
fieldTypes: FIELD_TYPE_ROWS,
|
|
snippet: { key: 'field', label: 'Field object', code: json(eventFields[2]) },
|
|
endpoints: [
|
|
endpoint(
|
|
'ct-list',
|
|
'GET',
|
|
'/v1/content-type/',
|
|
'List all custom content types.',
|
|
'Verified user',
|
|
verifiedAfterLogin([e401]),
|
|
json([{ ...eventType, fields: eventFields.slice(0, 3) }]),
|
|
),
|
|
endpoint(
|
|
'ct-get',
|
|
'GET',
|
|
'/v1/content-type/{key}',
|
|
'Get one content type with its fields.',
|
|
'Verified user',
|
|
verifiedAfterLogin([e401, contentTypeNotFound]),
|
|
json(eventType),
|
|
),
|
|
endpoint(
|
|
'ct-create',
|
|
'POST',
|
|
'/v1/content-type/',
|
|
'Create a content type. Needs the permission "Create and edit content types".',
|
|
'Needs permission',
|
|
[
|
|
e(400, BODY_MISMATCH),
|
|
e401,
|
|
eMissingPermission,
|
|
literal(409, 'content_type_key_taken'),
|
|
],
|
|
json(eventType),
|
|
{
|
|
status: 201,
|
|
prettyBody: true,
|
|
bodyKind: 'json',
|
|
body: [
|
|
{
|
|
name: 'key',
|
|
type: 'string',
|
|
required: true,
|
|
rule: "2 to 40 characters. Lowercase letters, digits and hyphens. Can't be changed later.",
|
|
},
|
|
{ name: 'name', type: 'string', required: true, rule: NAME_RULE },
|
|
{ name: 'icon', type: 'string', required: false, rule: 'Optional.' },
|
|
{ name: 'fields', type: 'object[]', required: true, rule: FIELDS_RULE },
|
|
],
|
|
bodySample: {
|
|
key: 'events',
|
|
name: 'Events',
|
|
icon: 'calendar',
|
|
fields: eventFields.slice(0, 3),
|
|
},
|
|
},
|
|
),
|
|
endpoint(
|
|
'ct-update',
|
|
'PUT',
|
|
'/v1/content-type/{key}',
|
|
'Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can\'t change. Needs the permission "Create and edit content types".',
|
|
'Needs permission',
|
|
[
|
|
e(400, BODY_MISMATCH),
|
|
e401,
|
|
eMissingPermission,
|
|
contentTypeNotFound,
|
|
literal(
|
|
409,
|
|
'schema_incompatible',
|
|
"Some existing entries don't fit the new fields. Nothing was changed.",
|
|
),
|
|
],
|
|
json(eventType),
|
|
{
|
|
prettyBody: true,
|
|
bodyKind: 'json',
|
|
body: [
|
|
{ name: 'name', type: 'string', required: true, rule: NAME_RULE },
|
|
{ name: 'icon', type: 'string', required: false, rule: 'Optional.' },
|
|
{ name: 'fields', type: 'object[]', required: true, rule: FIELDS_RULE },
|
|
],
|
|
bodySample: {
|
|
name: 'Events',
|
|
icon: 'calendar',
|
|
fields: eventFields.slice(0, 3),
|
|
},
|
|
},
|
|
),
|
|
endpoint(
|
|
'ct-delete',
|
|
'DELETE',
|
|
'/v1/content-type/{key}',
|
|
'Delete a content type. Only possible when it has no entries and no relation field points at it. Needs the permission "Delete content types".',
|
|
'Needs permission',
|
|
[
|
|
e401,
|
|
eMissingPermission,
|
|
contentTypeNotFound,
|
|
literal(409, 'content_type_has_entries'),
|
|
literal(409, 'content_type_referenced'),
|
|
],
|
|
NO_CONTENT_RESPONSE,
|
|
),
|
|
],
|
|
},
|
|
{
|
|
key: 'entries',
|
|
title: 'Entries',
|
|
description:
|
|
"Read and write the content of a custom type. Each route needs the matching content permission of the key creator's role.",
|
|
note: "An entry's `data` is an object keyed by field key, validated against the type. Responses add `media` (the resolved images for media fields, keyed by field key: id, filename, width, height, size and thumbnail_url, or { id, missing: true } if the image was deleted) and `relations` (the resolved first field of related entries).",
|
|
snippet: { key: 'entry', label: 'Entry', code: json(eventEntry()) },
|
|
endpoints: [
|
|
endpoint(
|
|
'en-list',
|
|
'GET',
|
|
'/v1/content-type/{key}/entry',
|
|
'List entries of a type, one page at a time. The response is an object with items, total, page and size, not a bare array like articles.',
|
|
'Verified user',
|
|
verifiedAfterLogin([e(400, INVALID_PAGE_SIZE_SORT), e401, contentTypeNotFound]),
|
|
json({ items: [eventEntry()], total: 1, page: 1, size: 25 }),
|
|
{
|
|
query: [
|
|
{
|
|
name: 'page',
|
|
type: 'integer',
|
|
required: false,
|
|
rule: '1 or higher. Default 1.',
|
|
},
|
|
{
|
|
name: 'size',
|
|
type: 'integer',
|
|
required: false,
|
|
rule: 'One of 5, 25, 50 or 100. Default 25.',
|
|
},
|
|
{
|
|
name: 'sort',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.',
|
|
},
|
|
],
|
|
},
|
|
),
|
|
endpoint(
|
|
'en-create',
|
|
'POST',
|
|
'/v1/content-type/{key}/entry',
|
|
'Create an entry.',
|
|
'Verified user',
|
|
verifiedAfterLogin([
|
|
e(400, BODY_MISMATCH),
|
|
e401,
|
|
contentTypeNotFound,
|
|
invalidEntryUnique,
|
|
invalidEntryRange,
|
|
]),
|
|
json(eventEntry()),
|
|
{
|
|
status: 201,
|
|
bodyKind: 'json',
|
|
searchKeys: ENTRY_SEARCH_KEYS,
|
|
body: [{ name: 'data', type: 'object', required: true, rule: ENTRY_DATA_RULE }],
|
|
bodySample: { data: eventData },
|
|
},
|
|
),
|
|
endpoint(
|
|
'en-get',
|
|
'GET',
|
|
'/v1/content-type/{key}/entry/{id}',
|
|
'Get one entry.',
|
|
'Verified user',
|
|
verifiedAfterLogin([e(400, ID_FORMAT), e401, entryNotFound, contentTypeNotFound]),
|
|
json(eventEntry()),
|
|
{ pathId: 31 },
|
|
),
|
|
endpoint(
|
|
'en-update',
|
|
'PUT',
|
|
'/v1/content-type/{key}/entry/{id}',
|
|
'Replace the data of an entry.',
|
|
'Verified user',
|
|
verifiedAfterLogin([
|
|
e(400, BODY_MISMATCH),
|
|
e401,
|
|
entryNotFound,
|
|
contentTypeNotFound,
|
|
invalidEntryUnique,
|
|
invalidEntryRange,
|
|
]),
|
|
json(eventEntry({ ...eventData, capacity: 150 })),
|
|
{
|
|
pathId: 31,
|
|
bodyKind: 'json',
|
|
searchKeys: ENTRY_SEARCH_KEYS,
|
|
body: [{ name: 'data', type: 'object', required: true, rule: ENTRY_DATA_RULE }],
|
|
bodySample: { data: { ...eventData, capacity: 150 } },
|
|
},
|
|
),
|
|
endpoint(
|
|
'en-delete',
|
|
'DELETE',
|
|
'/v1/content-type/{key}/entry/{id}',
|
|
'Delete an entry.',
|
|
'Verified user',
|
|
verifiedAfterLogin([e401, entryNotFound, contentTypeNotFound]),
|
|
NO_CONTENT_RESPONSE,
|
|
{ pathId: 31 },
|
|
),
|
|
],
|
|
},
|
|
{
|
|
key: 'builtin',
|
|
title: 'Built-in types',
|
|
description:
|
|
'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.',
|
|
note: 'Built-in types are read-only: they cannot be changed through the API.',
|
|
endpoints: [
|
|
endpoint(
|
|
'bt-list',
|
|
'GET',
|
|
'/v1/builtin-type/',
|
|
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user. Needs the permission "View content types".',
|
|
'Needs permission',
|
|
[e401, eMissingPermission],
|
|
json([
|
|
{ key: 'article', name: 'Articles', fields: articleBuiltinFields },
|
|
{ key: 'author', name: 'Authors', fields: authorBuiltinFields },
|
|
]),
|
|
),
|
|
endpoint(
|
|
'bt-get',
|
|
'GET',
|
|
'/v1/builtin-type/{key}',
|
|
'Get one built-in type. Keys are article, author, site, media and user. Needs the permission "View content types".',
|
|
'Needs permission',
|
|
[e401, eMissingPermission, literal(404, 'builtin_type_not_found')],
|
|
json({ key: 'author', name: 'Authors', fields: authorBuiltinFields }),
|
|
),
|
|
],
|
|
},
|
|
{
|
|
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. There is one article item per version, with its story_id and lang.',
|
|
'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'],
|
|
story_id: 5,
|
|
lang: 'en',
|
|
},
|
|
{ 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. Needs the permission "View settings".',
|
|
'Needs permission',
|
|
permissionErrors(),
|
|
json(PURGE_SETTINGS),
|
|
),
|
|
endpoint(
|
|
'med-purge-set',
|
|
'PATCH',
|
|
'/v1/media/purge-settings',
|
|
'Turn the automatic purge on or off, or change its interval. Needs the permission "Edit general settings".',
|
|
'Needs permission',
|
|
permissionErrors(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. Needs the permission "Delete files".',
|
|
'Needs permission',
|
|
permissionErrors(),
|
|
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. failed_groups counts groups that could not be purged; the others were still purged. Needs the permission "Delete files".',
|
|
'Needs permission',
|
|
permissionErrors(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,
|
|
authors_updated: 0,
|
|
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: 'sites',
|
|
title: 'Sites and authors',
|
|
description:
|
|
'Read the sites and authors your content belongs to. Creating and changing them needs a session.',
|
|
endpoints: [
|
|
endpoint(
|
|
'site-list',
|
|
'GET',
|
|
'/v1/site/',
|
|
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.',
|
|
'Verified user',
|
|
verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]),
|
|
json([
|
|
site(),
|
|
site({
|
|
id: 2,
|
|
name: 'Renew shop',
|
|
key: 'renew-shop',
|
|
base_url: 'https://shop.renew.example',
|
|
default_social_image_id: null,
|
|
default_robots: 'noindex',
|
|
locale: null,
|
|
is_default: false,
|
|
article_count: 4,
|
|
site_name: '',
|
|
title_template: '%title% - %site%',
|
|
default_description: '',
|
|
}),
|
|
]),
|
|
{
|
|
query: [
|
|
PAGE_QUERY,
|
|
SIZE_QUERY,
|
|
{
|
|
name: 'q',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.',
|
|
},
|
|
],
|
|
},
|
|
),
|
|
endpoint(
|
|
'site-languages',
|
|
'GET',
|
|
'/v1/site/{key}/languages',
|
|
'List the content languages of a site: the default first, then the other enabled ones, then the disabled ones. Each carries its version and published counts and its social locale. Send the revision back when you change them. A site with no confirmed default answers needs_setup true and no languages.',
|
|
'Verified user',
|
|
verified([e(401, UNAUTHORIZED), e(404, 'site_not_found')]),
|
|
json({
|
|
revision: '9f2c4d1e7a',
|
|
default: 'en',
|
|
needs_setup: false,
|
|
languages: [
|
|
{
|
|
lang: 'en',
|
|
enabled: true,
|
|
is_default: true,
|
|
version_count: 7,
|
|
published_count: 6,
|
|
social_locale: null,
|
|
social_locale_state: 'not_set',
|
|
},
|
|
{
|
|
lang: 'fr',
|
|
enabled: true,
|
|
is_default: false,
|
|
version_count: 4,
|
|
published_count: 2,
|
|
social_locale: 'fr_FR',
|
|
social_locale_state: 'set',
|
|
},
|
|
],
|
|
available: [
|
|
{ lang: 'pt', substitute: null },
|
|
{ lang: 'pt-PT', substitute: 'pt' },
|
|
],
|
|
}),
|
|
),
|
|
endpoint(
|
|
'author-list',
|
|
'GET',
|
|
'/v1/author/',
|
|
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.',
|
|
'Verified user',
|
|
verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]),
|
|
json({
|
|
items: [
|
|
author(),
|
|
author({
|
|
id: 7,
|
|
name: 'Camille Roche',
|
|
slug: 'camille-roche',
|
|
bio: 'Freelance writer on lime and stone.',
|
|
website: null,
|
|
user_id: null,
|
|
is_linked: false,
|
|
article_count: 3,
|
|
}),
|
|
],
|
|
total: 2,
|
|
page: 1,
|
|
size: 25,
|
|
}),
|
|
{
|
|
query: [
|
|
PAGE_QUERY,
|
|
SIZE_QUERY,
|
|
{
|
|
name: 'q',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'Up to 200 characters, matched in the name or the slug.',
|
|
},
|
|
],
|
|
},
|
|
),
|
|
],
|
|
},
|
|
{
|
|
key: 'users',
|
|
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: ownerRole,
|
|
status: 'accepted',
|
|
created_at: '2026-01-05T08:30:00Z',
|
|
},
|
|
{
|
|
id: 7,
|
|
kind: 'invite',
|
|
email: 'lea.martin@renew.example',
|
|
role: writerRole,
|
|
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.',
|
|
},
|
|
{
|
|
name: 'role_id',
|
|
type: 'number',
|
|
required: false,
|
|
rule: 'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.',
|
|
},
|
|
],
|
|
bodySample: { email: 'lea.martin@renew.example', role_id: 3 },
|
|
},
|
|
),
|
|
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(meWithAccess),
|
|
),
|
|
],
|
|
},
|
|
{
|
|
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',
|
|
}),
|
|
),
|
|
],
|
|
},
|
|
]
|
|
|
|
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'),
|
|
row('PATCH', '/v1/settings', 'Change the activity log retention'),
|
|
],
|
|
},
|
|
{
|
|
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'),
|
|
],
|
|
},
|
|
{
|
|
title: 'Sites and authors writes',
|
|
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'),
|
|
row('PUT', '/v1/site/{key}/languages', 'Replace the content languages of a site'),
|
|
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: 'Language setup · owner only',
|
|
rows: [
|
|
row('GET', '/v1/language/setup', 'Sites that still need a default language'),
|
|
row('POST', '/v1/language/setup', 'Confirm the default language of every pending site'),
|
|
],
|
|
},
|
|
{
|
|
title: 'Image variants',
|
|
rows: [
|
|
row('GET', '/v1/image-variants/', 'List variants'),
|
|
row('POST', '/v1/image-variants/', 'Create a variant'),
|
|
row('PUT', '/v1/image-variants/{name}', 'Replace a variant'),
|
|
row('PATCH', '/v1/image-variants/{name}', 'Update a variant'),
|
|
row('DELETE', '/v1/image-variants/{name}', 'Delete a variant'),
|
|
row('POST', '/v1/image-variants/{name}/purge', 'Purge the cache of a variant'),
|
|
row('GET', '/v1/image-variants/cache', 'Image cache status'),
|
|
row('POST', '/v1/image-variants/cache/purge', 'Purge the image cache'),
|
|
row('GET', '/v1/media/{id}/variants', 'List the variants of an image'),
|
|
row('POST', '/v1/media/{id}/variants', 'Add variants to an image'),
|
|
row('POST', '/v1/media/{id}/variants/{name}', 'Generate one variant of an image'),
|
|
row('DELETE', '/v1/media/{id}/variants/{name}', 'Delete one variant of an image'),
|
|
],
|
|
},
|
|
{
|
|
title: 'Activity',
|
|
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'),
|
|
],
|
|
},
|
|
]
|
|
|
|
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, VERIFY_REQUIRED],
|
|
codes: [MISSING_PERMISSION_BODY],
|
|
note: '`missing_permission` is the literal body, not a sentence. Site SEO defaults need the permission to manage sites. 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'],
|
|
codes: ['content_type_not_found', 'entry_not_found', 'builtin_type_not_found'],
|
|
note: 'Text in code style is the literal body of the response, not a sentence.',
|
|
},
|
|
{
|
|
status: 409,
|
|
meaning: 'Conflict',
|
|
messages: [
|
|
"There's already a pending invite for this address.",
|
|
'This invite has already been accepted.',
|
|
'Some of the images to merge changed. Refresh and try again.',
|
|
'This address already has an account.',
|
|
],
|
|
codes: [
|
|
'content_type_key_taken',
|
|
'content_type_has_entries',
|
|
'content_type_referenced',
|
|
'schema_incompatible',
|
|
'invalid_entry: <reason>',
|
|
'slug_taken',
|
|
],
|
|
note: 'On an entry, `invalid_entry: <reason>` is a 409 only when a unique field clashes. Any other field error is a 422.',
|
|
},
|
|
{
|
|
status: 422,
|
|
meaning: 'Unprocessable',
|
|
messages: [],
|
|
codes: ['invalid_entry: <reason>'],
|
|
note: 'An entry doesn\'t fit its content type. The body is `invalid_entry`, then ": " and the reason, for example `invalid_entry: capacity must be at most 5000`.',
|
|
},
|
|
{
|
|
status: 429,
|
|
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 sampleKey(path: string) {
|
|
if (path.startsWith('/v1/content-type')) {
|
|
return SAMPLE_CONTENT_TYPE_KEY
|
|
}
|
|
return path.startsWith('/v1/builtin-type') ? SAMPLE_BUILTIN_TYPE_KEY : SAMPLE_SITE_KEY
|
|
}
|
|
|
|
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
|
|
const keyed = item.path
|
|
.replace('{key}', sampleKey(item.path))
|
|
.replace('{lang}', PUBLIC_SAMPLE.lang)
|
|
.replace('{slug}', PUBLIC_SAMPLE.slug)
|
|
.replace('{story_id}', String(PUBLIC_SAMPLE.story))
|
|
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 bodyJson = item.prettyBody
|
|
? JSON.stringify(item.bodySample, null, 2)
|
|
: JSON.stringify(item.bodySample)
|
|
|
|
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 '${bodyJson}'`)
|
|
}
|
|
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(${bodyJson.replace(/\n/g, '\n ')})`)
|
|
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)
|
|
}
|