887 lines
22 KiB
TypeScript
887 lines
22 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
|
|
rateLimited: boolean
|
|
}
|
|
|
|
export interface ApiGroup {
|
|
key: string
|
|
title: string
|
|
description: string
|
|
endpoints: ApiEndpoint[]
|
|
}
|
|
|
|
export interface SessionRow {
|
|
method: HttpMethod
|
|
path: string
|
|
description: string
|
|
answers403: boolean
|
|
}
|
|
|
|
export interface SessionGroup {
|
|
title: string
|
|
rows: SessionRow[]
|
|
}
|
|
|
|
export interface PublicRow {
|
|
method: HttpMethod
|
|
path: string
|
|
description: string
|
|
}
|
|
|
|
export interface ErrorRow {
|
|
status: number
|
|
meaning: string
|
|
messages: string[]
|
|
}
|
|
|
|
export interface EndpointExamples {
|
|
fetch: string
|
|
curl: string
|
|
response: string
|
|
}
|
|
|
|
export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
|
|
export const NO_CONTENT_RESPONSE = '204 No Content'
|
|
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
|
|
export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.'
|
|
export const 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 MEDIA_QUERY: ApiField[] = [
|
|
{ name: 'q', type: 'string', required: false, rule: 'Search text.' },
|
|
{
|
|
name: 'hash',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'SHA-256 of the content, lowercased.',
|
|
},
|
|
{
|
|
name: 'cursor',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'The next_cursor of the previous page.',
|
|
},
|
|
{
|
|
name: 'filter',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'all, unused or duplicates. Default all.',
|
|
},
|
|
{
|
|
name: 'sort',
|
|
type: 'string',
|
|
required: false,
|
|
rule: 'newest, oldest, name or largest. Default newest.',
|
|
},
|
|
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
|
|
]
|
|
|
|
const json = (value: unknown) => JSON.stringify(value, null, 2)
|
|
|
|
const ARTICLE_TITLE = 'How we price a bathroom renovation'
|
|
|
|
const article = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 12,
|
|
title: ARTICLE_TITLE,
|
|
body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.',
|
|
body_format: 'markdown',
|
|
is_online: true,
|
|
created_at: '2026-03-12T09:14:00Z',
|
|
updated_at: '2026-09-10T07:00:00Z',
|
|
...overrides,
|
|
})
|
|
|
|
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 4,
|
|
filename: 'north-wall.jpg',
|
|
url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg',
|
|
size: 3900000,
|
|
content_type: 'image/jpeg',
|
|
width: 4032,
|
|
height: 3024,
|
|
sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
|
|
uploaded_by: 'marta',
|
|
created_at: '2026-09-10T07:00:00Z',
|
|
used_in: 2,
|
|
duplicate: false,
|
|
...overrides,
|
|
})
|
|
|
|
const me = {
|
|
id: 1,
|
|
username: 'marta',
|
|
email: 'marta@renew.example',
|
|
is_verified: true,
|
|
created_at: '2026-01-05T08:30:00Z',
|
|
updated_at: '2026-09-10T07:00:00Z',
|
|
display_name: 'Marta Alves',
|
|
role: 'owner',
|
|
}
|
|
|
|
const invite = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 7,
|
|
kind: 'invite',
|
|
email: 'lea.martin@renew.example',
|
|
status: 'pending',
|
|
created_at: '2026-09-10T07:00:00Z',
|
|
expires_at: '2026-09-13T07:00:00Z',
|
|
...overrides,
|
|
})
|
|
|
|
const e = (status: number, caption: string): ApiError => ({ status, caption })
|
|
|
|
const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)]
|
|
|
|
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
|
|
|
|
const ownerOrAdminErrors = (...own: ApiError[]) => [
|
|
...own,
|
|
e(401, UNAUTHORIZED),
|
|
e(403, OWNER_OR_ADMIN_MESSAGE),
|
|
]
|
|
|
|
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,
|
|
rateLimited: false,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
const ownerOnly = (
|
|
id: string,
|
|
method: HttpMethod,
|
|
path: string,
|
|
description: string,
|
|
errors: ApiError[],
|
|
response: string,
|
|
extra: Partial<ApiEndpoint> = {},
|
|
) =>
|
|
endpoint(
|
|
id,
|
|
method,
|
|
path,
|
|
description,
|
|
'Owner only',
|
|
[...errors, e(403, OWNER_REQUIRED_MESSAGE), e(429, TOO_MANY_REQUESTS)],
|
|
response,
|
|
{ rateLimited: true, pathId: path.includes('{id}') ? 7 : null, ...extra },
|
|
)
|
|
|
|
export const API_GROUPS: ApiGroup[] = [
|
|
{
|
|
key: 'articles',
|
|
title: 'Articles',
|
|
description: "Read, write and publish articles. Titles can't be changed after creation.",
|
|
endpoints: [
|
|
endpoint(
|
|
'art-list',
|
|
'GET',
|
|
'/v1/article/',
|
|
'List articles. Anonymous callers get online articles only.',
|
|
'Public',
|
|
articleErrors(),
|
|
json([article()]),
|
|
{ query: [FORMAT_QUERY] },
|
|
),
|
|
endpoint(
|
|
'art-get',
|
|
'GET',
|
|
'/v1/article/{id}',
|
|
'Get one article. Drafts answer 404 to anonymous callers.',
|
|
'Public',
|
|
articleErrors(...articleIdErrors),
|
|
json(article()),
|
|
{ query: [FORMAT_QUERY] },
|
|
),
|
|
endpoint(
|
|
'art-create',
|
|
'POST',
|
|
'/v1/article/',
|
|
'Create a draft article.',
|
|
'Verified user',
|
|
verified(
|
|
articleErrors(
|
|
e(400, BODY_MISMATCH),
|
|
e(400, 'Article title must be between 3 and 200 characters.'),
|
|
),
|
|
),
|
|
json(
|
|
article({
|
|
id: 13,
|
|
title: 'Choosing tiles for a small bathroom',
|
|
body: 'Small rooms show every grout line.',
|
|
is_online: false,
|
|
}),
|
|
),
|
|
{
|
|
query: [FORMAT_QUERY],
|
|
bodyKind: 'json',
|
|
body: [
|
|
{
|
|
name: 'title',
|
|
type: 'string',
|
|
required: true,
|
|
rule: "3 to 200 characters. Can't be changed later.",
|
|
},
|
|
{
|
|
name: 'body',
|
|
type: 'string',
|
|
required: true,
|
|
rule: 'Up to 10 000 characters.',
|
|
},
|
|
],
|
|
bodySample: {
|
|
title: 'Choosing tiles for a small bathroom',
|
|
body: 'Small rooms show every grout line.',
|
|
},
|
|
},
|
|
),
|
|
endpoint(
|
|
'art-update',
|
|
'PATCH',
|
|
'/v1/article/{id}',
|
|
'Update the body of an article.',
|
|
'Verified user',
|
|
verified(articleErrors(e(400, BODY_MISMATCH), ...articleIdErrors)),
|
|
json(article({ body: 'Updated text.', is_online: false })),
|
|
{
|
|
query: [FORMAT_QUERY],
|
|
bodyKind: 'json',
|
|
body: [
|
|
{
|
|
name: 'body',
|
|
type: 'string',
|
|
required: true,
|
|
rule: 'Up to 10 000 characters.',
|
|
},
|
|
],
|
|
bodySample: { body: 'Updated text.' },
|
|
},
|
|
),
|
|
endpoint(
|
|
'art-pub',
|
|
'PATCH',
|
|
'/v1/article/{id}/publish',
|
|
'Publish an article.',
|
|
'Verified user',
|
|
verified(articleErrors(...articleIdErrors)),
|
|
json(article({ is_online: true })),
|
|
{ query: [FORMAT_QUERY] },
|
|
),
|
|
endpoint(
|
|
'art-unpub',
|
|
'PATCH',
|
|
'/v1/article/{id}/unpublish',
|
|
'Unpublish an article.',
|
|
'Verified user',
|
|
verified(articleErrors(...articleIdErrors)),
|
|
json(article({ is_online: false })),
|
|
{ query: [FORMAT_QUERY] },
|
|
),
|
|
endpoint(
|
|
'art-del',
|
|
'DELETE',
|
|
'/v1/article/{id}',
|
|
'Delete an article.',
|
|
'Verified user',
|
|
verified(articleErrors(...articleIdErrors)),
|
|
NO_CONTENT_RESPONSE,
|
|
{ query: [FORMAT_QUERY] },
|
|
),
|
|
],
|
|
},
|
|
{
|
|
key: 'media',
|
|
title: 'Media',
|
|
description: 'The image library. One image can be used in many articles.',
|
|
endpoints: [
|
|
endpoint(
|
|
'med-list',
|
|
'GET',
|
|
'/v1/media/',
|
|
'List images.',
|
|
'Verified user',
|
|
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
|
|
json({
|
|
items: [mediaItem()],
|
|
next_cursor: null,
|
|
counts: { all: 1, unused: 0, duplicates: 0 },
|
|
}),
|
|
{ query: MEDIA_QUERY },
|
|
),
|
|
endpoint(
|
|
'med-up',
|
|
'POST',
|
|
'/v1/media/',
|
|
'Upload an image (multipart).',
|
|
'Verified user',
|
|
verified([
|
|
e(400, 'the image could not be read'),
|
|
e(401, UNAUTHORIZED),
|
|
e(413, 'the file exceeds the maximum upload size'),
|
|
e(415, 'the file must be a PNG, JPEG, WEBP, or SVG image'),
|
|
]),
|
|
json(mediaItem()),
|
|
{
|
|
pathId: null,
|
|
bodyKind: 'multipart',
|
|
body: [
|
|
{
|
|
name: 'file',
|
|
type: 'file',
|
|
required: true,
|
|
rule: 'PNG, JPEG, WEBP or SVG.',
|
|
},
|
|
],
|
|
bodySample: { file: '@north-wall.jpg' },
|
|
},
|
|
),
|
|
endpoint(
|
|
'med-ren',
|
|
'PATCH',
|
|
'/v1/media/{id}',
|
|
'Rename an image. The URL stays the same.',
|
|
'Verified user',
|
|
verified([
|
|
e(400, 'Enter a filename.'),
|
|
e(400, ID_FORMAT),
|
|
e(401, UNAUTHORIZED),
|
|
e(404, 'Image not found'),
|
|
]),
|
|
json(mediaItem({ filename: 'north-wall-01.jpg' })),
|
|
{
|
|
pathId: 4,
|
|
bodyKind: 'json',
|
|
body: [
|
|
{
|
|
name: 'filename',
|
|
type: 'string',
|
|
required: true,
|
|
rule: 'The new filename.',
|
|
},
|
|
],
|
|
bodySample: { filename: 'north-wall-01.jpg' },
|
|
},
|
|
),
|
|
endpoint(
|
|
'med-del',
|
|
'DELETE',
|
|
'/v1/media/{id}',
|
|
'Delete an image. Articles that use it show a broken image.',
|
|
'Verified user',
|
|
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
|
NO_CONTENT_RESPONSE,
|
|
{ pathId: 4 },
|
|
),
|
|
endpoint(
|
|
'med-merge',
|
|
'POST',
|
|
'/v1/media/merge',
|
|
'Merge identical copies into one image.',
|
|
'Verified user',
|
|
verified([
|
|
e(
|
|
400,
|
|
'Choose an image to keep and at least one other image to merge into it.',
|
|
),
|
|
e(401, UNAUTHORIZED),
|
|
e(409, 'Some of the images to merge changed. Refresh and try again.'),
|
|
]),
|
|
json({ articles_updated: 5, merged: 2 }),
|
|
{
|
|
bodyKind: 'json',
|
|
body: [
|
|
{
|
|
name: 'keep_id',
|
|
type: 'number',
|
|
required: true,
|
|
rule: 'Greater than 0. The image to keep.',
|
|
},
|
|
{
|
|
name: 'merge_ids',
|
|
type: 'number[]',
|
|
required: true,
|
|
rule: 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.',
|
|
},
|
|
],
|
|
bodySample: { keep_id: 4, merge_ids: [9, 11] },
|
|
},
|
|
),
|
|
endpoint(
|
|
'med-use',
|
|
'GET',
|
|
'/v1/media/{id}/usage',
|
|
'List the articles that use an image.',
|
|
'Verified user',
|
|
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
|
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
|
|
{ pathId: 4 },
|
|
),
|
|
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,
|
|
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()),
|
|
{
|
|
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_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'),
|
|
],
|
|
},
|
|
{
|
|
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: '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],
|
|
},
|
|
{
|
|
status: 404,
|
|
meaning: 'Not found',
|
|
messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
|
|
},
|
|
{
|
|
status: 409,
|
|
meaning: 'Conflict',
|
|
messages: [
|
|
"There's already a pending invite for this address.",
|
|
'This invite has already been accepted.',
|
|
],
|
|
},
|
|
{ status: 429, meaning: 'Too many requests', messages: [TOO_MANY_REQUESTS] },
|
|
{ status: 500, meaning: 'Server error', messages: ['Internal server error'] },
|
|
]
|
|
|
|
export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\n${SESSION_REQUIRED_MESSAGE}`
|
|
|
|
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
|
|
|
|
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
|
|
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
|
|
}
|
|
|
|
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {
|
|
const url = `${origin}${resolvedPath(item)}`
|
|
const isPublic = item.access === 'Public'
|
|
const isJson = item.bodyKind === 'json' && item.bodySample !== null
|
|
const isForm = item.bodyKind === 'multipart'
|
|
const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
|
|
|
|
const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`]
|
|
if (!isPublic) curlParts.push(` -H "${authHeader}"`)
|
|
if (isJson) {
|
|
curlParts.push(` -H "Content-Type: application/json"`)
|
|
curlParts.push(` -d '${JSON.stringify(item.bodySample)}'`)
|
|
}
|
|
if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`)
|
|
|
|
const options: string[] = []
|
|
if (item.method !== 'GET') options.push(`method: '${item.method}'`)
|
|
const headers = [
|
|
isPublic ? null : `Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
|
|
isJson ? `'Content-Type': 'application/json'` : null,
|
|
].filter((header) => header !== null)
|
|
if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`)
|
|
if (isJson) options.push(`body: JSON.stringify(${JSON.stringify(item.bodySample)})`)
|
|
if (isForm) options.push('body: form')
|
|
|
|
const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : ''
|
|
const call = `const res = await fetch('${url}'${
|
|
options.length > 0 ? `, {\n ${options.join(',\n ')}\n})` : ')'
|
|
}`
|
|
|
|
return {
|
|
fetch: formSetup + call,
|
|
curl: curlParts.join(' \\\n'),
|
|
response: item.response,
|
|
}
|
|
}
|
|
|
|
export function authExamples(origin: string) {
|
|
const url = `${origin}${AUTH_EXAMPLE_PATH}`
|
|
return {
|
|
curl: `curl "${url}" \\\n -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}"`,
|
|
fetch: `const res = await fetch('${url}', {\n headers: { Authorization: 'Bearer ${API_KEY_PLACEHOLDER}' }\n})\nconst me = await res.json()`,
|
|
}
|
|
}
|
|
|
|
export function allEndpoints() {
|
|
return API_GROUPS.flatMap((group) => group.endpoints)
|
|
}
|