feat: updated integration page with missing API routes
This commit is contained in:
1 parent
25d9c30b8a
commit
7c0ee21906
25 files changed
+2402
-350
No files matched your search
+564
-114
@@ -1,4 +1,4 @@
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
export type AccessLevel = 'Public' | 'Verified user' | 'Owner or admin' | 'Owner only'
|
||||
|
||||
export interface ApiField {
|
||||
@@ -11,6 +11,8 @@ export interface ApiField {
|
||||
export interface ApiError {
|
||||
status: number
|
||||
caption: string
|
||||
literal?: boolean
|
||||
tail?: string
|
||||
}
|
||||
|
||||
export type BodyKind = 'none' | 'json' | 'multipart'
|
||||
@@ -30,12 +32,29 @@ export interface ApiEndpoint {
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -61,6 +80,7 @@ export interface ErrorRow {
|
||||
status: number
|
||||
meaning: string
|
||||
messages: string[]
|
||||
codes?: string[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
@@ -75,6 +95,8 @@ 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 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.'
|
||||
|
||||
@@ -302,6 +324,8 @@ const article = (overrides: Record<string, unknown> = {}) => ({
|
||||
})
|
||||
|
||||
const SAMPLE_SITE_KEY = 'renew-blog'
|
||||
const SAMPLE_CONTENT_TYPE_KEY = 'events'
|
||||
const SAMPLE_BUILTIN_TYPE_KEY = 'author'
|
||||
|
||||
const resolvedSeo = () => ({
|
||||
title: `${ARTICLE_TITLE} | Renew`,
|
||||
@@ -452,6 +476,127 @@ const author = (overrides: Record<string, unknown> = {}) => ({
|
||||
...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',
|
||||
@@ -475,10 +620,27 @@ const invite = (overrides: Record<string, unknown> = {}) => ({
|
||||
|
||||
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 eOwnerOrAdmin = e(403, OWNER_OR_ADMIN_MESSAGE)
|
||||
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 ownerOrAdminErrors = (...own: ApiError[]) => [
|
||||
...own,
|
||||
e(401, UNAUTHORIZED),
|
||||
@@ -545,6 +707,8 @@ function endpoint(
|
||||
response,
|
||||
status: response === NO_CONTENT_RESPONSE ? 204 : 200,
|
||||
rateLimited: false,
|
||||
prettyBody: false,
|
||||
searchKeys: [],
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
@@ -843,6 +1007,255 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'ctypes',
|
||||
title: 'Content types',
|
||||
description:
|
||||
'Define your own kind of content, like events or recipes. An owner or admin creates 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.',
|
||||
'Owner or admin',
|
||||
[
|
||||
e(400, BODY_MISMATCH),
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
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.",
|
||||
'Owner or admin',
|
||||
[
|
||||
e(400, BODY_MISMATCH),
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
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.',
|
||||
'Owner or admin',
|
||||
[
|
||||
e401,
|
||||
eOwnerOrAdmin,
|
||||
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. Any verified user can write entries.',
|
||||
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.',
|
||||
'Owner or admin',
|
||||
[e401, eOwnerOrAdmin],
|
||||
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.',
|
||||
'Owner or admin',
|
||||
[e401, eOwnerOrAdmin, literal(404, 'builtin_type_not_found')],
|
||||
json({ key: 'author', name: 'Authors', fields: authorBuiltinFields }),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'media',
|
||||
title: 'Media',
|
||||
@@ -1060,7 +1473,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'med-purge-run',
|
||||
'POST',
|
||||
'/v1/media/purge',
|
||||
'Merge every group of identical copies. Send the preview fingerprint to refuse a changed library.',
|
||||
'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.',
|
||||
'Owner or admin',
|
||||
ownerOrAdminErrors(
|
||||
e(400, BODY_MISMATCH),
|
||||
@@ -1073,6 +1486,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
merged: 2,
|
||||
articles_updated: 5,
|
||||
sites_updated: 1,
|
||||
authors_updated: 0,
|
||||
failed_groups: 0,
|
||||
}),
|
||||
{
|
||||
@@ -1090,6 +1504,89 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
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(
|
||||
'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',
|
||||
@@ -1229,97 +1726,6 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
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 = (
|
||||
@@ -1342,6 +1748,7 @@ export const SESSION_GROUPS: SessionGroup[] = [
|
||||
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'),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1356,17 +1763,20 @@ export const SESSION_GROUPS: SessionGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sites · owner only',
|
||||
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'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Authors · owner and admin',
|
||||
rows: [
|
||||
row('POST', '/v1/author/', 'Create an author'),
|
||||
row(
|
||||
'PATCH',
|
||||
@@ -1382,25 +1792,30 @@ export const SESSION_GROUPS: SessionGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings · owner only',
|
||||
rows: [row('PATCH', '/v1/settings', 'Change the activity log retention')],
|
||||
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 log',
|
||||
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'),
|
||||
],
|
||||
},
|
||||
{
|
||||
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[] = [
|
||||
@@ -1442,13 +1857,20 @@ export const ERROR_ROWS: ErrorRow[] = [
|
||||
{
|
||||
status: 403,
|
||||
meaning: 'Forbidden',
|
||||
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
|
||||
messages: [
|
||||
SESSION_REQUIRED_MESSAGE,
|
||||
OWNER_OR_ADMIN_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'],
|
||||
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,
|
||||
@@ -1456,7 +1878,25 @@ export const ERROR_ROWS: ErrorRow[] = [
|
||||
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,
|
||||
@@ -1474,8 +1914,15 @@ export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\
|
||||
|
||||
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}', SAMPLE_SITE_KEY)
|
||||
const keyed = item.path.replace('{key}', sampleKey(item.path))
|
||||
return item.pathId === null ? keyed : keyed.replace('{id}', String(item.pathId))
|
||||
}
|
||||
|
||||
@@ -1485,12 +1932,15 @@ export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExa
|
||||
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 '${JSON.stringify(item.bodySample)}'`)
|
||||
curlParts.push(` -d '${bodyJson}'`)
|
||||
}
|
||||
if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`)
|
||||
|
||||
@@ -1501,7 +1951,7 @@ export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExa
|
||||
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 (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" : ''
|
||||
|
||||
Reference in new issue
Block a user