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
@@ -142,7 +142,17 @@ const snippetTabs = computed(() => [
|
||||
variant="outline"
|
||||
class="h-5 w-9 shrink-0 justify-center rounded-none border border-accented px-0 py-0 font-mono text-xs font-medium text-toned ring-0"
|
||||
/>
|
||||
<span class="min-w-0 text-pretty text-toned">{{ error.caption }}</span>
|
||||
<span v-if="error.literal" class="min-w-0 text-pretty text-toned"
|
||||
><code class="font-mono text-xs text-highlighted">{{
|
||||
error.caption
|
||||
}}</code
|
||||
><span v-if="error.tail" class="ml-1.5">{{
|
||||
translate(error.tail)
|
||||
}}</span></span
|
||||
>
|
||||
<span v-else class="min-w-0 text-pretty text-toned">{{
|
||||
error.caption
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ERROR_ROWS,
|
||||
LOGIN_FAMILY_CAPTION,
|
||||
NO_CONTENT_RESPONSE,
|
||||
OWNER_OR_ADMIN_MESSAGE,
|
||||
OWNER_REQUIRED_MESSAGE,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
RATE_LIMIT_SENTENCE,
|
||||
@@ -29,6 +30,21 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'POST /v1/article/{id}/seo/check',
|
||||
],
|
||||
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
|
||||
'Content types': [
|
||||
'GET /v1/content-type/',
|
||||
'GET /v1/content-type/{key}',
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
],
|
||||
Entries: [
|
||||
'GET /v1/content-type/{key}/entry',
|
||||
'POST /v1/content-type/{key}/entry',
|
||||
'GET /v1/content-type/{key}/entry/{id}',
|
||||
'PUT /v1/content-type/{key}/entry/{id}',
|
||||
'DELETE /v1/content-type/{key}/entry/{id}',
|
||||
],
|
||||
'Built-in types': ['GET /v1/builtin-type/', 'GET /v1/builtin-type/{key}'],
|
||||
Media: [
|
||||
'GET /v1/media/',
|
||||
'GET /v1/media/{id}',
|
||||
@@ -42,6 +58,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'GET /v1/media/purge-preview',
|
||||
'POST /v1/media/purge',
|
||||
],
|
||||
'Sites and authors': ['GET /v1/site/', 'GET /v1/author/'],
|
||||
'Users and invites': [
|
||||
'GET /v1/user/',
|
||||
'PATCH /v1/user/me',
|
||||
@@ -51,8 +68,6 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'GET /v1/auth/me',
|
||||
],
|
||||
Settings: ['GET /v1/settings'],
|
||||
Sites: ['GET /v1/site/'],
|
||||
Authors: ['GET /v1/author/'],
|
||||
}
|
||||
|
||||
const EXPECTED_SESSION: Record<string, string[]> = {
|
||||
@@ -61,6 +76,7 @@ const EXPECTED_SESSION: Record<string, string[]> = {
|
||||
'POST /v1/system/update/check',
|
||||
'POST /v1/system/update/download',
|
||||
'POST /v1/system/update/restart',
|
||||
'PATCH /v1/settings',
|
||||
],
|
||||
'Sign in and account': [
|
||||
'POST /v1/auth/login',
|
||||
@@ -70,21 +86,32 @@ const EXPECTED_SESSION: Record<string, string[]> = {
|
||||
'DELETE /v1/auth/register',
|
||||
'POST /v1/auth/password-reset',
|
||||
],
|
||||
'Sites · owner only': [
|
||||
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
|
||||
'Sites and authors writes': [
|
||||
'POST /v1/site/',
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
'DELETE /v1/site/{id}',
|
||||
],
|
||||
'Authors · owner and admin': [
|
||||
'POST /v1/author/',
|
||||
'PATCH /v1/author/{id}',
|
||||
'DELETE /v1/author/{id}',
|
||||
'POST /v1/author/me',
|
||||
],
|
||||
'Settings · owner only': ['PATCH /v1/settings'],
|
||||
'Activity log': ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
|
||||
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
|
||||
'Image variants': [
|
||||
'GET /v1/image-variants/',
|
||||
'POST /v1/image-variants/',
|
||||
'PUT /v1/image-variants/{name}',
|
||||
'PATCH /v1/image-variants/{name}',
|
||||
'DELETE /v1/image-variants/{name}',
|
||||
'POST /v1/image-variants/{name}/purge',
|
||||
'GET /v1/image-variants/cache',
|
||||
'POST /v1/image-variants/cache/purge',
|
||||
'GET /v1/media/{id}/variants',
|
||||
'POST /v1/media/{id}/variants',
|
||||
'POST /v1/media/{id}/variants/{name}',
|
||||
'DELETE /v1/media/{id}/variants/{name}',
|
||||
],
|
||||
Activity: ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
|
||||
}
|
||||
|
||||
const EXPECTED_PUBLIC = [
|
||||
@@ -185,6 +212,13 @@ const ME_KEYS = [
|
||||
'role',
|
||||
]
|
||||
|
||||
function sampleKey(path: string) {
|
||||
if (path.startsWith('/v1/content-type')) {
|
||||
return 'events'
|
||||
}
|
||||
return path.startsWith('/v1/builtin-type') ? 'author' : 'renew-blog'
|
||||
}
|
||||
|
||||
const label = (item: { method: string; path: string }) => `${item.method} ${item.path}`
|
||||
|
||||
function find(route: string) {
|
||||
@@ -208,29 +242,37 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 30 key-callable routes in 7 groups, in order', () => {
|
||||
it('holds exactly the 42 key-callable routes in 9 groups, in order', () => {
|
||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Content types',
|
||||
'Entries',
|
||||
'Built-in types',
|
||||
'Media',
|
||||
'Sites and authors',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
])
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(30)
|
||||
expect(allEndpoints()).toHaveLength(42)
|
||||
})
|
||||
|
||||
it('holds the 25 session-only rows in 7 sub-groups', () => {
|
||||
it('counts the routes of each group', () => {
|
||||
expect(API_GROUPS.map((group) => group.endpoints.length)).toEqual([
|
||||
8, 2, 5, 5, 2, 11, 2, 6, 1,
|
||||
])
|
||||
})
|
||||
|
||||
it('holds the 37 session-only rows in 6 sub-groups', () => {
|
||||
expect(
|
||||
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
|
||||
).toEqual(EXPECTED_SESSION)
|
||||
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(25)
|
||||
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(37)
|
||||
})
|
||||
|
||||
it('holds the 6 public auth flow rows', () => {
|
||||
@@ -252,7 +294,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(7 + 30 + 25 + 6)
|
||||
expect(described).toHaveLength(9 + 42 + 37 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -272,7 +314,7 @@ describe('Catalogue completeness', () => {
|
||||
'%s has method, path, chips, tables and examples',
|
||||
(_route, endpoint) => {
|
||||
const examples = endpointExamples(endpoint, ORIGIN)
|
||||
expect(['GET', 'POST', 'PATCH', 'DELETE']).toContain(endpoint.method)
|
||||
expect(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).toContain(endpoint.method)
|
||||
expect(endpoint.path.startsWith('/v1/')).toBe(true)
|
||||
expect(['Public', 'Verified user', 'Owner or admin', 'Owner only']).toContain(
|
||||
endpoint.access,
|
||||
@@ -280,7 +322,7 @@ describe('Catalogue completeness', () => {
|
||||
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
|
||||
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
|
||||
expect(examples.curl).toContain(
|
||||
`"${ORIGIN}${endpoint.path.replace('{key}', 'renew-blog').replace('{id}', String(endpoint.pathId))}"`,
|
||||
`"${ORIGIN}${endpoint.path.replace('{key}', sampleKey(endpoint.path)).replace('{id}', String(endpoint.pathId))}"`,
|
||||
)
|
||||
expect(examples.response).toBe(endpoint.response)
|
||||
expect(examples.response.trim()).not.toBe('')
|
||||
@@ -366,12 +408,17 @@ describe('SEO output routes', () => {
|
||||
})
|
||||
|
||||
describe('Owner or admin purge routes', () => {
|
||||
it('gives Owner or admin to the four purge routes only', () => {
|
||||
it('gives Owner or admin to the content type writes, the built-in types and the four purge routes', () => {
|
||||
expect(
|
||||
allEndpoints()
|
||||
.filter((endpoint) => endpoint.access === 'Owner or admin')
|
||||
.map(label),
|
||||
).toEqual([
|
||||
'POST /v1/content-type/',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'DELETE /v1/content-type/{key}',
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/builtin-type/{key}',
|
||||
'GET /v1/media/purge-settings',
|
||||
'PATCH /v1/media/purge-settings',
|
||||
'GET /v1/media/purge-preview',
|
||||
@@ -449,9 +496,10 @@ describe('Response samples use the real domain JSON keys', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('adds sites_updated to the purge result', () => {
|
||||
it('gives the purge result exactly the seven real keys', () => {
|
||||
expect(keysOf(parsed(find('POST /v1/media/purge')))).toEqual([
|
||||
'articles_updated',
|
||||
'authors_updated',
|
||||
'failed_groups',
|
||||
'groups_purged',
|
||||
'groups_total',
|
||||
@@ -742,37 +790,24 @@ describe('Per-endpoint error captions use the real strings', () => {
|
||||
describe('Session only rows', () => {
|
||||
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
|
||||
|
||||
it('claims 403 only on verify, DELETE register, sites, settings, activity, api-key and system rows', () => {
|
||||
expect(rows.filter((row) => row.answers403).map(label)).toEqual([
|
||||
'GET /v1/system/update',
|
||||
'POST /v1/system/update/check',
|
||||
'POST /v1/system/update/download',
|
||||
'POST /v1/system/update/restart',
|
||||
'POST /v1/auth/verify',
|
||||
'DELETE /v1/auth/register',
|
||||
'POST /v1/site/',
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
'DELETE /v1/site/{id}',
|
||||
'POST /v1/author/',
|
||||
'PATCH /v1/author/{id}',
|
||||
'DELETE /v1/author/{id}',
|
||||
'POST /v1/author/me',
|
||||
'PATCH /v1/settings',
|
||||
'GET /v1/activity',
|
||||
'GET /v1/activity/summary',
|
||||
'GET /v1/activity/export',
|
||||
'GET /v1/api-key',
|
||||
'POST /v1/api-key',
|
||||
'DELETE /v1/api-key/{id}',
|
||||
])
|
||||
it('claims 403 on every row except login, logout, register and password reset', () => {
|
||||
const excluded = [
|
||||
'POST /v1/auth/login',
|
||||
'POST /v1/auth/logout',
|
||||
'POST /v1/auth/register',
|
||||
'POST /v1/auth/password-reset',
|
||||
]
|
||||
expect(rows.filter((row) => row.answers403).map(label)).toEqual(
|
||||
rows.map(label).filter((route) => !excluded.includes(route)),
|
||||
)
|
||||
expect(rows.filter((row) => row.answers403)).toHaveLength(33)
|
||||
})
|
||||
|
||||
it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => {
|
||||
const group = SESSION_GROUPS.find(
|
||||
(candidate) => candidate.title === 'Authors · owner and admin',
|
||||
(candidate) => candidate.title === 'Sites and authors writes',
|
||||
)
|
||||
expect(group?.rows.map(label)).toEqual([
|
||||
expect(group?.rows.map(label).filter((route) => route.includes('/v1/author'))).toEqual([
|
||||
'POST /v1/author/',
|
||||
'PATCH /v1/author/{id}',
|
||||
'DELETE /v1/author/{id}',
|
||||
@@ -801,37 +836,52 @@ describe('Session only rows', () => {
|
||||
})
|
||||
|
||||
describe('Errors table', () => {
|
||||
it('lists the real statuses and messages', () => {
|
||||
expect(ERROR_ROWS.map((row) => [row.status, row.messages])).toEqual([
|
||||
it('lists the real statuses, messages and literal codes', () => {
|
||||
expect(ERROR_ROWS.map((row) => [row.status, row.messages, row.codes ?? []])).toEqual([
|
||||
[
|
||||
400,
|
||||
[
|
||||
'The request cannot be processed due to a mismatch in the format of the body.',
|
||||
'The expiry must be a date in the future.',
|
||||
],
|
||||
[],
|
||||
],
|
||||
[401, ['You must be logged in to access this resource.']],
|
||||
[401, ['You must be logged in to access this resource.'], []],
|
||||
[
|
||||
403,
|
||||
[
|
||||
SESSION_REQUIRED_MESSAGE,
|
||||
'Only the owner can do this.',
|
||||
OWNER_OR_ADMIN_MESSAGE,
|
||||
OWNER_REQUIRED_MESSAGE,
|
||||
'You must verify your account to access this resource.',
|
||||
],
|
||||
[],
|
||||
],
|
||||
[
|
||||
404,
|
||||
['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
|
||||
['content_type_not_found', 'entry_not_found', 'builtin_type_not_found'],
|
||||
],
|
||||
[
|
||||
409,
|
||||
[
|
||||
"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.',
|
||||
],
|
||||
[
|
||||
'content_type_key_taken',
|
||||
'content_type_has_entries',
|
||||
'content_type_referenced',
|
||||
'schema_incompatible',
|
||||
'invalid_entry: <reason>',
|
||||
'slug_taken',
|
||||
],
|
||||
],
|
||||
[429, ['Too many requests. Please try again later.']],
|
||||
[500, ['Internal server error']],
|
||||
[422, [], ['invalid_entry: <reason>']],
|
||||
[429, ['Too many requests. Please try again later.'], []],
|
||||
[500, ['Internal server error'], []],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -873,3 +923,123 @@ describe('Examples', () => {
|
||||
expect(API_KEY_PLACEHOLDER.startsWith('rcms_')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom content types round', () => {
|
||||
const group = (key: string) => API_GROUPS.find((candidate) => candidate.key === key)!
|
||||
|
||||
it('gives the Content types group its field types table and sample field', () => {
|
||||
const types = group('ctypes')
|
||||
expect(types.fieldTypes?.map((row) => row.type)).toEqual([
|
||||
'text',
|
||||
'richtext',
|
||||
'number',
|
||||
'boolean',
|
||||
'date',
|
||||
'select',
|
||||
'media',
|
||||
'relation',
|
||||
])
|
||||
expect(JSON.parse(types.snippet!.code)).toEqual({
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: null,
|
||||
relation: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('answers an entry list as an object and an entry with media keyed by field key', () => {
|
||||
expect(keysOf(parsed(find('GET /v1/content-type/{key}/entry')))).toEqual([
|
||||
'items',
|
||||
'page',
|
||||
'size',
|
||||
'total',
|
||||
])
|
||||
const entry = parsed(find('GET /v1/content-type/{key}/entry/{id}'))
|
||||
expect(keysOf(entry)).toEqual(
|
||||
[
|
||||
'id',
|
||||
'content_type',
|
||||
'data',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'media',
|
||||
'relations',
|
||||
].sort(),
|
||||
)
|
||||
expect(Object.keys(entry.media)).toEqual(['cover'])
|
||||
})
|
||||
|
||||
it('answers a built-in type with key, name and fields and no icon', () => {
|
||||
for (const route of ['GET /v1/builtin-type/{key}']) {
|
||||
expect(keysOf(parsed(find(route)))).toEqual(['fields', 'key', 'name'])
|
||||
}
|
||||
for (const item of parsed(find('GET /v1/builtin-type/'))) {
|
||||
expect(keysOf(item)).toEqual(['fields', 'key', 'name'])
|
||||
}
|
||||
})
|
||||
|
||||
it('marks the new codes as literal bodies and keeps the 409 and 422 entry errors', () => {
|
||||
const literalErrors = (route: string) =>
|
||||
find(route)
|
||||
.errors.filter((error) => error.literal)
|
||||
.map((error) => `${error.status} ${error.caption}`)
|
||||
expect(literalErrors('POST /v1/content-type/')).toEqual(['409 content_type_key_taken'])
|
||||
expect(literalErrors('PUT /v1/content-type/{key}')).toEqual([
|
||||
'404 content_type_not_found',
|
||||
'409 schema_incompatible',
|
||||
])
|
||||
expect(literalErrors('DELETE /v1/content-type/{key}')).toEqual([
|
||||
'404 content_type_not_found',
|
||||
'409 content_type_has_entries',
|
||||
'409 content_type_referenced',
|
||||
])
|
||||
expect(literalErrors('POST /v1/content-type/{key}/entry')).toEqual([
|
||||
'404 content_type_not_found',
|
||||
'409 invalid_entry: title must be unique',
|
||||
'422 invalid_entry: capacity must be at most 5000',
|
||||
])
|
||||
expect(literalErrors('GET /v1/builtin-type/{key}')).toEqual(['404 builtin_type_not_found'])
|
||||
})
|
||||
|
||||
it('takes the entry data and indexes the events keys as body fields', () => {
|
||||
for (const route of [
|
||||
'POST /v1/content-type/{key}/entry',
|
||||
'PUT /v1/content-type/{key}/entry/{id}',
|
||||
]) {
|
||||
expect(find(route).body.map((field) => field.name)).toEqual(['data'])
|
||||
expect(find(route).searchKeys).toEqual([
|
||||
'title',
|
||||
'starts_on',
|
||||
'capacity',
|
||||
'online',
|
||||
'category',
|
||||
'cover',
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the sample keys in the examples', () => {
|
||||
expect(endpointExamples(find('GET /v1/content-type/{key}'), ORIGIN).curl).toContain(
|
||||
`${ORIGIN}/v1/content-type/events"`,
|
||||
)
|
||||
expect(endpointExamples(find('GET /v1/builtin-type/{key}'), ORIGIN).curl).toContain(
|
||||
`${ORIGIN}/v1/builtin-type/author"`,
|
||||
)
|
||||
expect(
|
||||
endpointExamples(find('PUT /v1/content-type/{key}/entry/{id}'), ORIGIN).fetch,
|
||||
).toContain("method: 'PUT'")
|
||||
})
|
||||
|
||||
it('pretty prints the body of the content type writes', () => {
|
||||
const created = endpointExamples(find('POST /v1/content-type/'), ORIGIN)
|
||||
expect(created.curl).toContain('-d \'{\n "key": "events"')
|
||||
})
|
||||
})
|
||||
+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" : ''
|
||||
|
||||
@@ -950,8 +950,8 @@ export const messages = {
|
||||
'API reference': 'Référence de l’API',
|
||||
'Everything a developer needs to build on RenewCMS.':
|
||||
'Tout ce dont une personne développeuse a besoin pour construire sur RenewCMS.',
|
||||
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.':
|
||||
'RenewCMS est headless : il stocke votre contenu et votre propre code l’affiche. Il y a deux façons d’y accéder. L’accès public en lecture seule permet à un site de lire les articles publiés. Les clés d’API permettent à un script ou à un service de gérer les articles, les médias et les utilisateurs sans le back-office.',
|
||||
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.':
|
||||
'RenewCMS est headless : il stocke votre contenu et votre propre code l’affiche. Il y a deux façons d’y accéder. L’accès public en lecture seule permet à un site de lire les articles publiés. Les clés d’API permettent à un script ou à un service de gérer les articles, les médias et les utilisateurs, ainsi que les types de contenu et les entrées, sans le back-office.',
|
||||
'No key': 'Sans clé',
|
||||
'No key needed. Two read-only routes, and only online articles are returned.':
|
||||
'Aucune clé nécessaire. Deux routes en lecture seule, et seuls les articles publiés sont renvoyés.',
|
||||
@@ -961,8 +961,8 @@ export const messages = {
|
||||
'Envoyez `Authorization: Bearer rcms_…`. Les requêtes agissent comme la personne qui a créé la clé, avec son rôle (propriétaire ou administrateur). Une clé n’a jamais plus de droits que son créateur.',
|
||||
'Keys start with `rcms_` and are shown in full once, when you create them.':
|
||||
'Les clés commencent par `rcms_` et ne sont affichées en entier qu’une fois, à leur création.',
|
||||
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.':
|
||||
'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié.',
|
||||
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.':
|
||||
'Une clé ne peut pas créer, lister ni révoquer de clés d’API, vérifier ou annuler une inscription, ni appeler quoi que ce soit sous `/v1/system`. Ces routes répondent 403 « This action needs a real session. » Une clé invalide, inconnue, expirée ou révoquée reçoit 401, même sur les routes publiques, tout comme une clé dont l’utilisateur a été supprimé ou n’est pas vérifié. Les routes propriétaire et administrateur répondent 403 à une clé dont le créateur n’est ni l’un ni l’autre.',
|
||||
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.':
|
||||
'Chaque clé peut faire 120 requêtes par minute. Au-delà, l’API répond 429 avec un en-tête `Retry-After`.',
|
||||
'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.':
|
||||
@@ -2741,6 +2741,96 @@ export const messages = {
|
||||
'Status codes and messages': 'Codes d’état et messages',
|
||||
'Images, CORS and ?format=': 'Images, CORS et ?format=',
|
||||
'{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API',
|
||||
'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.':
|
||||
'Définissez votre propre type de contenu, comme des événements ou des recettes. Un propriétaire ou un administrateur crée un type composé de 1 à 50 champs ordonnés ; le type a alors ses propres routes d’entrées.',
|
||||
'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.':
|
||||
'Un type de contenu a une `key` (2 à 40 lettres minuscules, chiffres ou tirets, choisie à la création et jamais modifiée) et des champs. Chaque champ a une `key` (lettres, chiffres et tirets bas, commençant par une lettre), un `label`, un `type` et des règles.',
|
||||
'Field object': 'Objet champ',
|
||||
string: 'chaîne',
|
||||
'min/max = length in characters. Can be unique.':
|
||||
'min/max = longueur en caractères. Peut être unique.',
|
||||
'string (Markdown)': 'chaîne (Markdown)',
|
||||
'min/max = length. Cannot be unique.': 'min/max = longueur. Ne peut pas être unique.',
|
||||
number: 'nombre',
|
||||
'min/max = value. Can be unique.': 'min/max = valeur. Peut être unique.',
|
||||
'true/false': 'true/false',
|
||||
'No min/max. Cannot be unique.': 'Pas de min/max. Ne peut pas être unique.',
|
||||
'string "YYYY-MM-DD"': 'chaîne « AAAA-MM-JJ »',
|
||||
'min/max = earliest/latest date. Can be unique.':
|
||||
'min/max = date la plus ancienne/la plus récente. Peut être unique.',
|
||||
'one string from `options` (1 to 50 choices)': 'une chaîne parmi `options` (1 à 50 choix)',
|
||||
'Can be unique.': 'Peut être unique.',
|
||||
'the numeric id of an uploaded image (see Media)':
|
||||
'l’identifiant numérique d’une image envoyée (voir Médias)',
|
||||
'ids of entries of another content type; `relation: { content_type, kind }`, kind one_to_one | one_to_many | many_to_many':
|
||||
'identifiants d’entrées d’un autre type de contenu ; `relation: { content_type, kind }`, kind valant one_to_one | one_to_many | many_to_many',
|
||||
'List all custom content types.': 'Liste tous les types de contenu personnalisés.',
|
||||
'Get one content type with its fields.': 'Renvoie un type de contenu avec ses champs.',
|
||||
'Create a content type.': 'Crée un type de contenu.',
|
||||
"2 to 40 characters. Lowercase letters, digits and hyphens. Can't be changed later.":
|
||||
'2 à 40 caractères. Lettres minuscules, chiffres et tirets. Ne peut pas être modifiée ensuite.',
|
||||
'Up to 100 characters.': 'Jusqu’à 100 caractères.',
|
||||
'Optional.': 'Facultatif.',
|
||||
'1 to 50 fields, in order. See Field types.':
|
||||
'1 à 50 champs, dans l’ordre. Voir Types de champ.',
|
||||
"Replace the name, icon and fields of a type. The key never changes. Saved field keys and types can't change.":
|
||||
'Remplace le nom, l’icône et les champs d’un type. La clé ne change jamais. Les clés et types des champs enregistrés ne peuvent pas changer.',
|
||||
"Some existing entries don't fit the new fields. Nothing was changed.":
|
||||
'Certaines entrées existantes ne conviennent pas aux nouveaux champs. Rien n’a été modifié.',
|
||||
'Delete a content type. Only possible when it has no entries and no relation field points at it.':
|
||||
'Supprime un type de contenu. Possible seulement s’il n’a aucune entrée et si aucun champ de relation ne pointe vers lui.',
|
||||
'Read and write the content of a custom type. Any verified user can write entries.':
|
||||
'Lisez et écrivez le contenu d’un type personnalisé. Tout utilisateur vérifié peut écrire des entrées.',
|
||||
"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).":
|
||||
'Les `data` d’une entrée forment un objet indexé par clé de champ, validé selon le type. Les réponses ajoutent `media` (les images résolues des champs média, indexées par clé de champ : id, filename, width, height, size et thumbnail_url, ou { id, missing: true } si l’image a été supprimée) et `relations` (le premier champ résolu des entrées liées).',
|
||||
'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.':
|
||||
'Liste les entrées d’un type, une page à la fois. La réponse est un objet avec items, total, page et size, et non un tableau simple comme pour les articles.',
|
||||
'id, created_at or updated_at, then :asc or :desc. For example updated_at:desc.':
|
||||
'id, created_at ou updated_at, puis :asc ou :desc. Par exemple updated_at:desc.',
|
||||
'Create an entry.': 'Crée une entrée.',
|
||||
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover.":
|
||||
'Les clés sont les clés de champ du type. Pour les événements : title, starts_on, capacity, online, category et cover.',
|
||||
'Get one entry.': 'Renvoie une entrée.',
|
||||
'Replace the data of an entry.': 'Remplace les données d’une entrée.',
|
||||
'Delete an entry.': 'Supprime une entrée.',
|
||||
'Built-in types': 'Types intégrés',
|
||||
'The fields of Articles, Authors, Sites, Media and Users, in the same shape as the fields of a content type.':
|
||||
'Les champs des articles, auteurs, sites, médias et utilisateurs, dans la même forme que les champs d’un type de contenu.',
|
||||
'Built-in types are read-only: they cannot be changed through the API.':
|
||||
'Les types intégrés sont en lecture seule : ils ne peuvent pas être modifiés par l’API.',
|
||||
'List the built-in types (Articles, Authors, Sites, Media, Users) and their fields. Keys are article, author, site, media and user.':
|
||||
'Liste les types intégrés (articles, auteurs, sites, médias, utilisateurs) et leurs champs. Les clés sont article, author, site, media et user.',
|
||||
'Get one built-in type. Keys are article, author, site, media and user.':
|
||||
'Renvoie un type intégré. Les clés sont article, author, site, media et user.',
|
||||
'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.':
|
||||
'Fusionne chaque groupe de copies identiques. Envoyez l’empreinte de l’aperçu pour refuser une bibliothèque modifiée. failed_groups compte les groupes qui n’ont pas pu être purgés ; les autres l’ont été.',
|
||||
'Sites and authors': 'Sites et auteurs',
|
||||
'Read the sites and authors your content belongs to. Creating and changing them needs a session.':
|
||||
'Lisez les sites et les auteurs auxquels votre contenu appartient. Les créer et les modifier demande une session.',
|
||||
'Sites and authors writes': 'Écritures des sites et des auteurs',
|
||||
'List variants': 'Lister les variantes',
|
||||
'Create a variant': 'Créer une variante',
|
||||
'Replace a variant': 'Remplacer une variante',
|
||||
'Update a variant': 'Mettre à jour une variante',
|
||||
'Delete a variant': 'Supprimer une variante',
|
||||
'Purge the cache of a variant': 'Purger le cache d’une variante',
|
||||
'Image cache status': 'État du cache d’images',
|
||||
'Purge the image cache': 'Purger le cache d’images',
|
||||
'List the variants of an image': 'Lister les variantes d’une image',
|
||||
'Add variants to an image': 'Ajouter des variantes à une image',
|
||||
'Generate one variant of an image': 'Générer une variante d’une image',
|
||||
'Delete one variant of an image': 'Supprimer une variante d’une image',
|
||||
'Text in code style is the literal body of the response, not a sentence.':
|
||||
'Le texte en style code est le corps littéral de la réponse, pas une phrase.',
|
||||
'On an entry, `invalid_entry: <reason>` is a 409 only when a unique field clashes. Any other field error is a 422.':
|
||||
'Sur une entrée, `invalid_entry: <reason>` est un 409 seulement quand un champ unique entre en conflit. Toute autre erreur de champ est un 422.',
|
||||
Unprocessable: 'Non traitable',
|
||||
'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`.':
|
||||
'Une entrée ne convient pas à son type de contenu. Le corps est `invalid_entry`, puis « : » et la raison, par exemple `invalid_entry: capacity must be at most 5000`.',
|
||||
'Field types': 'Types de champ',
|
||||
'Value in an entry': 'Valeur dans une entrée',
|
||||
'1 or higher. Default 1.': '1 ou plus. 1 par défaut.',
|
||||
'One of 5, 25, 50 or 100. Default 25.': '5, 25, 50 ou 100. 25 par défaut.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -77,14 +77,14 @@ describe('Sites and authors in the editor, articles and profile screens: message
|
||||
})
|
||||
|
||||
describe('Catalogue groups of Sites and Authors in French', () => {
|
||||
const groups = API_GROUPS.filter((group) => ['sites', 'authors'].includes(group.key))
|
||||
const sessionGroups = SESSION_GROUPS.filter((group) => /^(Sites|Authors)/.test(group.title))
|
||||
const groups = API_GROUPS.filter((group) => group.key === 'sites')
|
||||
const sessionGroups = SESSION_GROUPS.filter((group) => /^Sites/.test(group.title))
|
||||
|
||||
it('covers the four group titles of the feature', () => {
|
||||
it('covers the group titles of the feature', () => {
|
||||
expect([
|
||||
...groups.map((group) => group.title),
|
||||
...sessionGroups.map((group) => group.title),
|
||||
]).toEqual(['Sites', 'Authors', 'Sites · owner only', 'Authors · owner and admin'])
|
||||
]).toEqual(['Sites and authors', 'Sites and authors writes'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -51,18 +51,27 @@ describe('index', () => {
|
||||
'auth',
|
||||
'articles',
|
||||
'seo',
|
||||
'ctypes',
|
||||
'entries',
|
||||
'builtin',
|
||||
'media',
|
||||
'sites',
|
||||
'images',
|
||||
'users',
|
||||
'settings',
|
||||
'sites',
|
||||
'authors',
|
||||
'images',
|
||||
'session',
|
||||
'errors',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('holds 85 endpoints: 42 documented, 37 session only and 6 public auth', () => {
|
||||
expect(index.endpoints).toHaveLength(85)
|
||||
expect(index.endpoints.filter((entry) => entry.cardId !== null)).toHaveLength(42)
|
||||
expect(index.endpoints.filter((entry) => entry.session)).toHaveLength(37)
|
||||
expect(index.endpoints.filter((entry) => entry.key.startsWith('public:'))).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('never indexes code samples or response examples', () => {
|
||||
expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([])
|
||||
expect(search('"total"').endpoints).toEqual([])
|
||||
@@ -164,12 +173,13 @@ describe('matching', () => {
|
||||
it('finds sections by title, description and keywords', () => {
|
||||
expect(routes(search('media').sections)).toEqual(['section Media'])
|
||||
expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' })
|
||||
expect(search('media').sections[0]!.entry.sectionId).toBe('media')
|
||||
const login = search('login')
|
||||
expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login'])
|
||||
expect(routes(login.sections)).toEqual(['section Session only'])
|
||||
expect(login.sections[0]!.entry).toMatchObject({
|
||||
locked: true,
|
||||
description: '25 routes, not available to API keys',
|
||||
description: '37 routes, not available to API keys',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -189,6 +199,58 @@ describe('matching', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom content types', () => {
|
||||
it.each([
|
||||
['media', 16, 1],
|
||||
['404', 23, 1],
|
||||
['login', 1, 1],
|
||||
['entry', 7, 1],
|
||||
['capacity', 2, 0],
|
||||
['schema_incompatible', 1, 0],
|
||||
['content type', 10, 2],
|
||||
])('counts "%s" as %i endpoints and %i sections', (query, endpoints, sections) => {
|
||||
const result = search(query)
|
||||
expect(result.endpoints).toHaveLength(endpoints)
|
||||
expect(result.sections).toHaveLength(sections)
|
||||
})
|
||||
|
||||
it('also matches "entries" for the term entry, in text and in highlights', () => {
|
||||
const keys = search('entry').endpoints.map((hit) => hit.entry.key)
|
||||
expect(keys).toContain('ep:ct-delete')
|
||||
expect(keys).toContain('ep:ct-update')
|
||||
expect(routes(search('entry').sections)).toEqual(['section Entries'])
|
||||
expect(highlightSegments('Entries and entry', ['entry'])).toEqual([
|
||||
{ text: 'Entries', marked: true },
|
||||
{ text: ' and ', marked: false },
|
||||
{ text: 'entry', marked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('labels the events keys as body fields and a code as an error', () => {
|
||||
const capacity = search('capacity').endpoints
|
||||
expect(capacity.map((hit) => hit.via?.kind)).toEqual(['body', 'body'])
|
||||
expect(search('starts_on').endpoints.map((hit) => hit.via)).toEqual([
|
||||
{ kind: 'body', text: 'starts_on' },
|
||||
{ kind: 'body', text: 'starts_on' },
|
||||
])
|
||||
const [schema] = search('schema_incompatible').endpoints
|
||||
expect(schema!.via!.kind).toBe('error')
|
||||
expect(schema!.via!.text.startsWith('409 schema_incompatible')).toBe(true)
|
||||
expect(routes(search('schema_incompatible').endpoints)).toEqual([
|
||||
'PUT /v1/content-type/{key}',
|
||||
])
|
||||
})
|
||||
|
||||
it('finds the new sections by their keywords', () => {
|
||||
expect(routes(search('schema').sections)).toEqual([
|
||||
'section Content types',
|
||||
'section Built-in types',
|
||||
])
|
||||
expect(routes(search('author').sections)).toContain('section Sites and authors')
|
||||
expect(routes(search('purge').sections)).toEqual(['section Media'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HttpMethod } from '@/data/apiCatalogue'
|
||||
import { IMAGES_AFTER_GROUP, type ApiGroup, type HttpMethod } from '@/data/apiCatalogue'
|
||||
|
||||
export const SECTION_TITLE = 'text-[1.125rem]/6.5 font-semibold text-highlighted'
|
||||
|
||||
@@ -16,10 +16,13 @@ const METHOD_BASE =
|
||||
|
||||
export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted`
|
||||
|
||||
const PATCH_CLASS = `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`
|
||||
|
||||
export const METHOD_CLASS: Record<HttpMethod, string> = {
|
||||
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
|
||||
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
|
||||
PATCH: `${METHOD_BASE} border-warning bg-(--rc-warning-soft) text-warning`,
|
||||
PATCH: PATCH_CLASS,
|
||||
PUT: PATCH_CLASS,
|
||||
DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`,
|
||||
}
|
||||
|
||||
@@ -30,3 +33,8 @@ export function splitInlineCode(text: string) {
|
||||
export function endpointAnchor(id: string) {
|
||||
return `ep-${id}`
|
||||
}
|
||||
|
||||
export function groupsAroundImages(groups: ApiGroup[]) {
|
||||
const split = groups.findIndex((group) => group.key === IMAGES_AFTER_GROUP) + 1
|
||||
return { before: groups.slice(0, split), after: groups.slice(split) }
|
||||
}
|
||||
+57
-11
@@ -1,10 +1,14 @@
|
||||
import { t, translate } from '@/i18n'
|
||||
import { groupsAroundImages } from '@/lib/apiReference'
|
||||
import {
|
||||
API_GROUPS,
|
||||
ERROR_ROWS,
|
||||
LOGIN_FAMILY_CAPTION,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
SESSION_GROUPS,
|
||||
type ApiEndpoint,
|
||||
type ApiError,
|
||||
type ApiGroup,
|
||||
type HttpMethod,
|
||||
} from '@/data/apiCatalogue'
|
||||
|
||||
@@ -63,6 +67,18 @@ export interface SearchIndex {
|
||||
sections: SectionEntry[]
|
||||
}
|
||||
|
||||
const ENTRY_TERM = 'entry'
|
||||
const ENTRY_PLURAL = 'entries'
|
||||
|
||||
const GROUP_KEYWORDS: Record<string, string> = {
|
||||
seo: 'seo sitemap',
|
||||
ctypes: 'custom fields field types schema',
|
||||
entries: 'content type custom data',
|
||||
builtin: 'builtin fields schema',
|
||||
media: 'purge',
|
||||
sites: 'site author',
|
||||
}
|
||||
|
||||
const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me']
|
||||
|
||||
export function firstSentence(text: string) {
|
||||
@@ -74,6 +90,17 @@ export function queryTerms(query: string) {
|
||||
return query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function errorText(error: ApiError) {
|
||||
const caption = translate(error.caption)
|
||||
return error.tail
|
||||
? `${error.status} ${caption} ${translate(error.tail)}`
|
||||
: `${error.status} ${caption}`
|
||||
}
|
||||
|
||||
function bodyKeys(endpoint: ApiEndpoint) {
|
||||
return [...new Set([...endpoint.body.map((field) => field.name), ...endpoint.searchKeys])]
|
||||
}
|
||||
|
||||
function endpointEntries(): EndpointEntry[] {
|
||||
const documented = API_GROUPS.flatMap((group) =>
|
||||
group.endpoints.map<EndpointEntry>((endpoint) => ({
|
||||
@@ -91,11 +118,11 @@ function endpointEntries(): EndpointEntry[] {
|
||||
kind: 'query',
|
||||
text: field.name,
|
||||
})),
|
||||
...endpoint.body.map<SearchMatch>((field) => ({ kind: 'body', text: field.name })),
|
||||
...bodyKeys(endpoint).map<SearchMatch>((text) => ({ kind: 'body', text })),
|
||||
],
|
||||
errors: endpoint.errors.map<SearchMatch>((error) => ({
|
||||
kind: 'error',
|
||||
text: `${error.status} ${translate(error.caption)}`,
|
||||
text: errorText(error),
|
||||
})),
|
||||
})),
|
||||
)
|
||||
@@ -154,6 +181,14 @@ function sectionEntries(): SectionEntry[] {
|
||||
locked,
|
||||
keywords,
|
||||
})
|
||||
const { before, after } = groupsAroundImages(API_GROUPS)
|
||||
const groupSection = (group: ApiGroup) =>
|
||||
section(
|
||||
group.key,
|
||||
translate(group.title),
|
||||
routeCount(group.endpoints.length),
|
||||
GROUP_KEYWORDS[group.key],
|
||||
)
|
||||
return [
|
||||
section(
|
||||
'auth',
|
||||
@@ -161,15 +196,14 @@ function sectionEntries(): SectionEntry[] {
|
||||
t('Public access and API keys'),
|
||||
'bearer api key rcms rate limit 429 base url public',
|
||||
),
|
||||
...API_GROUPS.map((group) =>
|
||||
section(group.key, translate(group.title), routeCount(group.endpoints.length)),
|
||||
),
|
||||
...before.map(groupSection),
|
||||
section(
|
||||
'images',
|
||||
t('Images and variants'),
|
||||
t('The ?v= parameter'),
|
||||
'variant image width height format quality',
|
||||
),
|
||||
...after.map(groupSection),
|
||||
section(
|
||||
'session',
|
||||
t('Session only'),
|
||||
@@ -196,19 +230,23 @@ export function buildSearchIndex(): SearchIndex {
|
||||
return { endpoints: endpointEntries(), sections: sectionEntries() }
|
||||
}
|
||||
|
||||
function includesTerm(text: string, term: string) {
|
||||
return text.includes(term) || (term === ENTRY_TERM && text.includes(ENTRY_PLURAL))
|
||||
}
|
||||
|
||||
function primaryText(entry: EndpointEntry) {
|
||||
return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase()
|
||||
}
|
||||
|
||||
function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined {
|
||||
if (primaryText(entry).includes(term)) {
|
||||
if (includesTerm(primaryText(entry), term)) {
|
||||
return null
|
||||
}
|
||||
const field = entry.fields.find((item) => item.text.toLowerCase().includes(term))
|
||||
const field = entry.fields.find((item) => includesTerm(item.text.toLowerCase(), term))
|
||||
if (field) {
|
||||
return field
|
||||
}
|
||||
return entry.errors.find((item) => item.text.toLowerCase().includes(term))
|
||||
return entry.errors.find((item) => includesTerm(item.text.toLowerCase(), term))
|
||||
}
|
||||
|
||||
function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null {
|
||||
@@ -225,7 +263,7 @@ function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null
|
||||
|
||||
function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null {
|
||||
const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase()
|
||||
return terms.every((term) => text.includes(term)) ? { entry, via: null } : null
|
||||
return terms.every((term) => includesTerm(text, term)) ? { entry, via: null } : null
|
||||
}
|
||||
|
||||
export function suggestedHits(index: SearchIndex): SearchHit[] {
|
||||
@@ -254,14 +292,22 @@ function escapePattern(term: string) {
|
||||
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function termPattern(term: string) {
|
||||
return term === ENTRY_TERM ? 'entr(?:y|ies)' : escapePattern(term)
|
||||
}
|
||||
|
||||
function isMarked(part: string, terms: Set<string>) {
|
||||
return terms.has(part) || (part === ENTRY_PLURAL && terms.has(ENTRY_TERM))
|
||||
}
|
||||
|
||||
export function highlightSegments(text: string, terms: string[]): HighlightSegment[] {
|
||||
if (terms.length === 0) {
|
||||
return [{ text, marked: false }]
|
||||
}
|
||||
const pattern = new RegExp(`(${terms.map(escapePattern).join('|')})`, 'gi')
|
||||
const pattern = new RegExp(`(${terms.map(termPattern).join('|')})`, 'gi')
|
||||
const lowered = new Set(terms)
|
||||
return text
|
||||
.split(pattern)
|
||||
.filter((part) => part !== '')
|
||||
.map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) }))
|
||||
.map((part) => ({ text: part, marked: isMarked(part.toLowerCase(), lowered) }))
|
||||
}
|
||||
@@ -22,12 +22,15 @@ import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
|
||||
import {
|
||||
API_GROUPS,
|
||||
ERROR_ROWS,
|
||||
FIELD_TYPES_ANCHOR,
|
||||
ERROR_SAMPLE,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
SESSION_GROUPS,
|
||||
authExamples,
|
||||
type ApiGroup,
|
||||
type ErrorRow,
|
||||
} from '@/data/apiCatalogue'
|
||||
import { SECTION_TITLE, endpointAnchor } from '@/lib/apiReference'
|
||||
import { SECTION_TITLE, endpointAnchor, groupsAroundImages } from '@/lib/apiReference'
|
||||
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
|
||||
|
||||
const SPY_OFFSET_PX = 120
|
||||
@@ -45,13 +48,17 @@ const errorSampleTabs = computed(() => [
|
||||
|
||||
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
|
||||
|
||||
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
|
||||
|
||||
function groupSection(group: ApiGroup) {
|
||||
return { id: group.key, label: translate(group.title) }
|
||||
}
|
||||
|
||||
const sections = computed(() => [
|
||||
{ id: 'auth', label: t('Authentication') },
|
||||
...API_GROUPS.map((group) => ({
|
||||
id: group.key,
|
||||
label: translate(group.title),
|
||||
})),
|
||||
...groupsBeforeImages.map(groupSection),
|
||||
{ id: 'images', label: t('Images and variants') },
|
||||
...groupsAfterImages.map(groupSection),
|
||||
{ id: 'session', label: t('Session only') },
|
||||
{ id: 'errors', label: t('Errors') },
|
||||
{ id: 'notes', label: t('Notes') },
|
||||
@@ -67,7 +74,7 @@ const authItems = computed(() => {
|
||||
|
||||
const authNotes = computed(() => [
|
||||
t(
|
||||
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.',
|
||||
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Owner and admin routes answer 403 to a key whose creator is neither.',
|
||||
),
|
||||
t(
|
||||
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
|
||||
@@ -110,8 +117,64 @@ const errorColumns = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
function quoteMessages(messages: string[]) {
|
||||
return messages.map((message) => `"${message}"`).join(' / ')
|
||||
interface MessagePart {
|
||||
text: string
|
||||
code: boolean
|
||||
}
|
||||
|
||||
function messageParts(row: ErrorRow) {
|
||||
const parts: MessagePart[] = []
|
||||
const push = (text: string, code: boolean) => {
|
||||
if (parts.length > 0) {
|
||||
parts.push({ text: ' / ', code: false })
|
||||
}
|
||||
parts.push({ text, code })
|
||||
}
|
||||
row.messages.forEach((message) => push(`"${message}"`, false))
|
||||
row.codes?.forEach((code) => push(code, true))
|
||||
return parts
|
||||
}
|
||||
|
||||
const fieldTypeColumns = computed(() => [
|
||||
{
|
||||
accessorKey: 'type',
|
||||
get header() {
|
||||
return t('Type')
|
||||
},
|
||||
meta: { class: { th: 'w-22', td: 'w-22' } },
|
||||
},
|
||||
{
|
||||
accessorKey: 'value',
|
||||
get header() {
|
||||
return t('Value in an entry')
|
||||
},
|
||||
meta: { class: { th: 'lg:w-1/2', td: 'lg:w-1/2' } },
|
||||
},
|
||||
{
|
||||
accessorKey: 'rules',
|
||||
get header() {
|
||||
return t('Rules')
|
||||
},
|
||||
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
|
||||
},
|
||||
])
|
||||
|
||||
const pageBlocks = [
|
||||
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
||||
{ kind: 'images' as const, key: 'images', group: null },
|
||||
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
||||
]
|
||||
|
||||
function snippetTabs(group: ApiGroup) {
|
||||
return group.snippet
|
||||
? [
|
||||
{
|
||||
k: group.snippet.key,
|
||||
label: translate(group.snippet.label),
|
||||
code: group.snippet.code,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
||||
function routeCount(count: number) {
|
||||
@@ -371,7 +434,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
<p class="max-w-180 text-base text-pretty text-toned">
|
||||
{{
|
||||
t(
|
||||
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.',
|
||||
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@@ -458,33 +521,88 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-for="group in API_GROUPS"
|
||||
:key="group.key"
|
||||
:data-anchor="group.key"
|
||||
:aria-labelledby="`h-${group.key}`"
|
||||
:class="SECTION_CLASS"
|
||||
>
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h2 :id="`h-${group.key}`" :class="SECTION_TITLE">
|
||||
{{ translate(group.title) }}
|
||||
</h2>
|
||||
<span class="text-muted">{{ routeCount(group.endpoints.length) }}</span>
|
||||
</div>
|
||||
<span class="text-pretty text-toned">{{ translate(group.description) }}</span>
|
||||
<div class="border border-t-0 border-default bg-default">
|
||||
<EndpointCard
|
||||
v-for="endpoint in group.endpoints"
|
||||
:key="endpoint.id"
|
||||
:ref="(card) => setCard(endpoint.id, card)"
|
||||
:endpoint="endpoint"
|
||||
:origin="origin"
|
||||
:flash="flashedCard === endpoint.id ? flashMode : null"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<IntegrationImages :origin="origin" />
|
||||
<template v-for="block in pageBlocks" :key="block.key">
|
||||
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
|
||||
<section
|
||||
v-else
|
||||
:data-anchor="block.group.key"
|
||||
:aria-labelledby="`h-${block.group.key}`"
|
||||
:class="SECTION_CLASS"
|
||||
>
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h2 :id="`h-${block.group.key}`" :class="SECTION_TITLE">
|
||||
{{ translate(block.group.title) }}
|
||||
</h2>
|
||||
<span class="text-muted">{{
|
||||
routeCount(block.group.endpoints.length)
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="text-pretty text-toned">{{
|
||||
translate(block.group.description)
|
||||
}}</span>
|
||||
<div v-if="block.group.note" class="flex gap-2.5 text-base text-toned">
|
||||
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
|
||||
<span class="text-pretty"
|
||||
><CodeText :text="translate(block.group.note)"
|
||||
/></span>
|
||||
</div>
|
||||
<div
|
||||
v-if="block.group.fieldTypes"
|
||||
:data-anchor="FIELD_TYPES_ANCHOR"
|
||||
class="flex scroll-mt-16 flex-col gap-1.5 lg:scroll-mt-8"
|
||||
>
|
||||
<span class="font-semibold text-highlighted">{{
|
||||
t('Field types')
|
||||
}}</span>
|
||||
<UTable
|
||||
:data="block.group.fieldTypes"
|
||||
:columns="fieldTypeColumns"
|
||||
:ui="{
|
||||
root: 'border border-default bg-default',
|
||||
base: 'table-fixed',
|
||||
thead: 'bg-elevated [&>tr]:border-t-0',
|
||||
separator: 'hidden',
|
||||
th: 'px-2.5 py-1.5 text-sm font-medium text-muted',
|
||||
td: 'px-2.5 py-2 align-top text-sm whitespace-normal text-toned',
|
||||
tbody: 'divide-y-0',
|
||||
tr: 'border-t border-default',
|
||||
}"
|
||||
>
|
||||
<template #type-cell="{ row }">
|
||||
<span class="font-mono text-xs font-medium text-highlighted">{{
|
||||
row.original.type
|
||||
}}</span>
|
||||
</template>
|
||||
<template #value-cell="{ row }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-pretty"
|
||||
><CodeText :text="translate(row.original.value)"
|
||||
/></span>
|
||||
<span class="text-pretty lg:hidden"
|
||||
><CodeText :text="translate(row.original.rules)"
|
||||
/></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #rules-cell="{ row }">
|
||||
<span class="text-pretty"
|
||||
><CodeText :text="translate(row.original.rules)"
|
||||
/></span>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
<CodeSnippet v-if="block.group.snippet" :tabs="snippetTabs(block.group)" />
|
||||
<div class="border border-t-0 border-default bg-default">
|
||||
<EndpointCard
|
||||
v-for="endpoint in block.group.endpoints"
|
||||
:key="endpoint.id"
|
||||
:ref="(card) => setCard(endpoint.id, card)"
|
||||
:endpoint="endpoint"
|
||||
:origin="origin"
|
||||
:flash="flashedCard === endpoint.id ? flashMode : null"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
@@ -492,7 +610,7 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
{{ t('Session only') }}
|
||||
</h2>
|
||||
<span class="text-muted">{{
|
||||
t('{0} routes, made for a browser session', [sessionRouteCount])
|
||||
t('{0} routes, not available to API keys', [sessionRouteCount])
|
||||
}}</span>
|
||||
</div>
|
||||
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
|
||||
@@ -532,29 +650,45 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
translate(row.original.meaning)
|
||||
}}</span>
|
||||
<div class="flex flex-col gap-0.5 lg:hidden">
|
||||
<span class="text-pretty text-toned">{{
|
||||
quoteMessages(row.original.messages)
|
||||
}}</span>
|
||||
<span class="text-pretty text-toned"
|
||||
><template
|
||||
v-for="(part, index) in messageParts(row.original)"
|
||||
:key="index"
|
||||
><code
|
||||
v-if="part.code"
|
||||
class="font-mono text-xs text-highlighted"
|
||||
>{{ part.text }}</code
|
||||
><template v-else>{{ part.text }}</template></template
|
||||
></span
|
||||
>
|
||||
<span
|
||||
v-if="row.original.note"
|
||||
class="text-pretty text-muted"
|
||||
data-testid="error-note"
|
||||
>{{ translate(row.original.note) }}</span
|
||||
>
|
||||
><CodeText :text="translate(row.original.note)"
|
||||
/></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #messages-cell="{ row }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-pretty">{{
|
||||
quoteMessages(row.original.messages)
|
||||
}}</span>
|
||||
<span class="text-pretty"
|
||||
><template
|
||||
v-for="(part, index) in messageParts(row.original)"
|
||||
:key="index"
|
||||
><code
|
||||
v-if="part.code"
|
||||
class="font-mono text-xs text-highlighted"
|
||||
>{{ part.text }}</code
|
||||
><template v-else>{{ part.text }}</template></template
|
||||
></span
|
||||
>
|
||||
<span
|
||||
v-if="row.original.note"
|
||||
class="text-pretty text-muted"
|
||||
data-testid="error-note"
|
||||
>{{ translate(row.original.note) }}</span
|
||||
>
|
||||
><CodeText :text="translate(row.original.note)"
|
||||
/></span>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const ORIGIN = 'https://cms.renew.example'
|
||||
const LIST_PATH = '/v1/image-variants'
|
||||
const BASE = `${ORIGIN}/media/north-wall.jpg`
|
||||
const SECTION_ORDER = ['settings', 'images', 'session', 'errors']
|
||||
const SECTION_ORDER = ['images', 'settings', 'session', 'errors']
|
||||
const BODY_UNKNOWN =
|
||||
'{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }'
|
||||
const BODY_UNSUPPORTED =
|
||||
@@ -269,7 +269,7 @@ describe('I1. Many variants', () => {
|
||||
const labels = Array.from(
|
||||
document.body.querySelectorAll('nav[aria-label="On this page"] button'),
|
||||
).map((item) => item.textContent?.trim())
|
||||
expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Session only') - 1)
|
||||
expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Sites and authors') + 1)
|
||||
|
||||
const request = requestSnippet()
|
||||
expect(tabs(request).map((tab) => tab.textContent?.trim())).toEqual([
|
||||
|
||||
@@ -344,13 +344,20 @@ describe('8c Typing media', () => {
|
||||
|
||||
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
||||
const rows = optionRoutes()
|
||||
expect(rows.slice(0, 6).every((route) => route!.includes('/v1/media'))).toBe(true)
|
||||
expect(rows.slice(0, 6)).toEqual([
|
||||
'GET /v1/builtin-type/',
|
||||
'GET /v1/media/',
|
||||
'GET /v1/media/{id}',
|
||||
'POST /v1/media/',
|
||||
'PATCH /v1/media/{id}',
|
||||
'DELETE /v1/media/{id}',
|
||||
])
|
||||
expect(rows.at(-1)).toBe('Section Media')
|
||||
expect(options().at(-1)!.textContent).toContain('11 routes')
|
||||
expect(document.body.textContent).toContain('12 results')
|
||||
expect(liveRegion()).toBe('12 results, showing 7')
|
||||
expect(document.body.textContent).toContain('17 results')
|
||||
expect(liveRegion()).toBe('17 results, showing 7')
|
||||
expect(document.body.textContent).toContain(
|
||||
'5 more endpoints. Keep typing to narrow the list.',
|
||||
'10 more endpoints. Keep typing to narrow the list.',
|
||||
)
|
||||
const marks = Array.from(document.body.querySelectorAll('[role="option"] mark'))
|
||||
expect(marks.length).toBeGreaterThan(0)
|
||||
@@ -374,12 +381,11 @@ describe('8d More endpoints than the cap', () => {
|
||||
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
|
||||
'Section Errors. Status codes and messages',
|
||||
)
|
||||
const total = Number(/(\d+) results/.exec(document.body.textContent!)![1])
|
||||
expect(total).toBeGreaterThan(7)
|
||||
expect(document.body.textContent).toContain('24 results')
|
||||
expect(document.body.textContent).toContain(
|
||||
`${total - 7} more endpoints. Keep typing to narrow the list.`,
|
||||
'17 more endpoints. Keep typing to narrow the list.',
|
||||
)
|
||||
expect(liveRegion()).toBe(`${total} results, showing 7`)
|
||||
expect(liveRegion()).toBe('24 results, showing 7')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -434,7 +440,7 @@ describe('8f Session-only route', () => {
|
||||
expect(path.classList.contains('font-normal')).toBe(true)
|
||||
expect(route!.innerHTML).toContain('border-dashed')
|
||||
expect(section!.getAttribute('aria-label')).toBe(
|
||||
'Section Session only. 25 routes, not available to API keys',
|
||||
'Section Session only. 37 routes, not available to API keys',
|
||||
)
|
||||
expect(section!.querySelector('svg')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('2 results')
|
||||
@@ -551,6 +557,91 @@ describe('Roles and live region', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('9f to 9i Content types and entries', () => {
|
||||
async function searchFor(query: string) {
|
||||
const wrapper = await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type(query)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('9f lists the entry routes, matches "entries" and caps with the plural line', async () => {
|
||||
await searchFor('entry')
|
||||
|
||||
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
||||
expect(optionRoutes()).toEqual([
|
||||
'GET /v1/content-type/{key}/entry',
|
||||
'POST /v1/content-type/{key}/entry',
|
||||
'GET /v1/content-type/{key}/entry/{id}',
|
||||
'PUT /v1/content-type/{key}/entry/{id}',
|
||||
'DELETE /v1/content-type/{key}/entry/{id}',
|
||||
'PUT /v1/content-type/{key}',
|
||||
'Section Entries',
|
||||
])
|
||||
expect(document.body.textContent).toContain('8 results')
|
||||
expect(liveRegion()).toBe('8 results, showing 7')
|
||||
expect(document.body.textContent).toContain(
|
||||
'1 more endpoint. Keep typing to narrow the list.',
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('1 more endpoints')
|
||||
})
|
||||
|
||||
it('9g labels the capacity key as a Body field on POST and PUT entry', async () => {
|
||||
await searchFor('capacity')
|
||||
|
||||
expect(optionRoutes()).toEqual([
|
||||
'POST /v1/content-type/{key}/entry',
|
||||
'PUT /v1/content-type/{key}/entry/{id}',
|
||||
])
|
||||
for (const row of options()) {
|
||||
expect(row.textContent).toContain('Body field')
|
||||
expect(row.querySelector('mark')!.textContent).toBe('capacity')
|
||||
}
|
||||
expect(document.body.textContent).toContain('2 results')
|
||||
expect(liveRegion()).toBe('2 results')
|
||||
})
|
||||
|
||||
it('9h labels schema_incompatible as an Error on PUT /v1/content-type/{key}', async () => {
|
||||
await searchFor('schema_incompatible')
|
||||
|
||||
expect(optionRoutes()).toEqual(['PUT /v1/content-type/{key}'])
|
||||
expect(options()[0]!.textContent).toContain('Error')
|
||||
expect(options()[0]!.textContent).toContain('409 schema_incompatible')
|
||||
expect(liveRegion()).toBe('1 result')
|
||||
})
|
||||
|
||||
it('9i lists Content types and Entries in the Sections group', async () => {
|
||||
await searchFor('content type')
|
||||
|
||||
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
||||
expect(optionRoutes().slice(-2)).toEqual(['Section Content types', 'Section Entries'])
|
||||
expect(options()).toHaveLength(8)
|
||||
expect(document.body.textContent).toContain('12 results')
|
||||
expect(liveRegion()).toBe('12 results, showing 8')
|
||||
})
|
||||
|
||||
it('selects PUT /v1/content-type/{key}, expands it, flashes it and reads Content types', async () => {
|
||||
const wrapper = await searchFor('schema_incompatible')
|
||||
await pressInPalette('Enter')
|
||||
await wrapper.vm.$nextTick()
|
||||
await settle()
|
||||
|
||||
const button = cardFor(wrapper, 'ct-update').get('button[aria-expanded]')
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
expect(document.activeElement).toBe(button.element)
|
||||
expect(button.classes()).toContain('endpoint-flash')
|
||||
expect(window.location.hash).toBe('#ep-ct-update')
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
expect(index.get('button[aria-current="true"]').text()).toBe('Content types')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(FLASH_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(button.classes()).not.toContain('endpoint-flash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('8h Selecting an endpoint', () => {
|
||||
async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') {
|
||||
await openWithSlash()
|
||||
|
||||
@@ -20,12 +20,14 @@ const SECTION_LABELS = [
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Content types',
|
||||
'Entries',
|
||||
'Built-in types',
|
||||
'Media',
|
||||
'Sites and authors',
|
||||
'Images and variants',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
'Images and variants',
|
||||
'Session only',
|
||||
'Errors',
|
||||
'Notes',
|
||||
@@ -47,7 +49,7 @@ function stubClipboard() {
|
||||
return writeText
|
||||
}
|
||||
|
||||
async function mountIntegration() {
|
||||
async function mountIntegration(host?: HTMLElement) {
|
||||
stubOrigin(STUB_ORIGIN)
|
||||
fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
@@ -57,7 +59,7 @@ async function mountIntegration() {
|
||||
session.status = 'ready'
|
||||
await router.push('/integration')
|
||||
const wrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
attachTo: host ?? document.body,
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
@@ -130,7 +132,7 @@ describe('Default, every card collapsed', () => {
|
||||
expect(wrapper.findAll('h1').map((heading) => heading.text())).toContain('API reference')
|
||||
expect(wrapper.text()).toContain('Everything a developer needs to build on RenewCMS.')
|
||||
expect(wrapper.text()).toContain(
|
||||
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.',
|
||||
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
|
||||
)
|
||||
expect(wrapper.text()).toContain(STUB_ORIGIN)
|
||||
expect(findButton(wrapper, 'Copy')).toBeTruthy()
|
||||
@@ -149,6 +151,7 @@ describe('Default, every card collapsed', () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
expect(index.findAll('button')).toHaveLength(14)
|
||||
expect(index.findAll('button').map((button) => button.text())).toEqual(SECTION_LABELS)
|
||||
const jump = wrapper.get('button[aria-label="Jump to section"]')
|
||||
expect(jump.text()).toContain('Jump to')
|
||||
@@ -171,18 +174,150 @@ describe('Default, every card collapsed', () => {
|
||||
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
||||
})
|
||||
|
||||
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
|
||||
it.each([
|
||||
['articles', 'Articles', '8 routes', 8],
|
||||
['seo', 'Search and sharing', '2 routes', 2],
|
||||
['ctypes', 'Content types', '5 routes', 5],
|
||||
['entries', 'Entries', '5 routes', 5],
|
||||
['builtin', 'Built-in types', '2 routes', 2],
|
||||
['media', 'Media', '11 routes', 11],
|
||||
['sites', 'Sites and authors', '2 routes', 2],
|
||||
['users', 'Users and invites', '6 routes', 6],
|
||||
['settings', 'Settings', '1 route', 1],
|
||||
])('titles the %s group %s with %s', async (id, title, count, cards) => {
|
||||
const wrapper = await mountIntegration()
|
||||
const group = section(wrapper, id)
|
||||
|
||||
expect(group.get('h2').text()).toBe(title)
|
||||
expect(group.text()).toContain(count)
|
||||
expect(group.findAllComponents(EndpointCard)).toHaveLength(cards)
|
||||
group.findAllComponents(EndpointCard).forEach((item) => {
|
||||
expect(item.text()).toContain('API key OK')
|
||||
})
|
||||
})
|
||||
|
||||
it('lists the groups on the page in the order of the index', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const ids = wrapper
|
||||
.findAll('section[data-anchor]')
|
||||
.map((item) => item.attributes('data-anchor'))
|
||||
expect(ids).toEqual([
|
||||
'auth',
|
||||
'articles',
|
||||
'seo',
|
||||
'ctypes',
|
||||
'entries',
|
||||
'builtin',
|
||||
'media',
|
||||
'sites',
|
||||
'images',
|
||||
'users',
|
||||
'settings',
|
||||
'session',
|
||||
'errors',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['GET /v1/content-type/', 'Verified user'],
|
||||
['GET /v1/content-type/{key}', 'Verified user'],
|
||||
['POST /v1/content-type/', 'Owner or admin'],
|
||||
['PUT /v1/content-type/{key}', 'Owner or admin'],
|
||||
['DELETE /v1/content-type/{key}', 'Owner or admin'],
|
||||
['GET /v1/content-type/{key}/entry', 'Verified user'],
|
||||
['POST /v1/content-type/{key}/entry', 'Verified user'],
|
||||
['GET /v1/content-type/{key}/entry/{id}', 'Verified user'],
|
||||
['PUT /v1/content-type/{key}/entry/{id}', 'Verified user'],
|
||||
['DELETE /v1/content-type/{key}/entry/{id}', 'Verified user'],
|
||||
['GET /v1/builtin-type/', 'Owner or admin'],
|
||||
['GET /v1/builtin-type/{key}', 'Owner or admin'],
|
||||
['GET /v1/media/purge-settings', 'Owner or admin'],
|
||||
['PATCH /v1/media/purge-settings', 'Owner or admin'],
|
||||
['GET /v1/media/purge-preview', 'Owner or admin'],
|
||||
['POST /v1/media/purge', 'Owner or admin'],
|
||||
['GET /v1/site/', 'Verified user'],
|
||||
['GET /v1/author/', 'Verified user'],
|
||||
])('shows %s with the chip %s and API key OK', async (route, access) => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const text = row(wrapper, route).text()
|
||||
expect(text).toContain(route.split(' ')[0]!)
|
||||
expect(text).toContain(access)
|
||||
expect(text).toContain('API key OK')
|
||||
})
|
||||
|
||||
it('reads the descriptions of the Content types, Entries and Built-in types groups', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(section(wrapper, 'ctypes').text()).toContain(
|
||||
'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.',
|
||||
)
|
||||
expect(section(wrapper, 'entries').text()).toContain(
|
||||
'Read and write the content of a custom type. Any verified user can write entries.',
|
||||
)
|
||||
const builtin = section(wrapper, 'builtin').text()
|
||||
expect(builtin).toContain('Built-in types are read-only')
|
||||
expect(builtin).toContain('Keys are article, author, site, media and user.')
|
||||
})
|
||||
|
||||
it('puts the ct-fields anchor above the first Content types route, outside the index', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const group = section(wrapper, 'ctypes')
|
||||
const anchor = group.get('[data-anchor="ct-fields"]')
|
||||
|
||||
const firstRoute = group.get('[data-anchor="ep-ct-list"]')
|
||||
expect(
|
||||
['articles', 'seo', 'media', 'users', 'settings'].map(
|
||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||
),
|
||||
).toEqual([8, 2, 11, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('8 routes')
|
||||
expect(section(wrapper, 'seo').text()).toContain('2 routes')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
||||
anchor.element.compareDocumentPosition(firstRoute.element) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
expect(index.text()).not.toContain('Field types')
|
||||
expect(anchor.text()).toContain('Field types')
|
||||
const headers = anchor.findAll('th').map((item) => item.text())
|
||||
expect(headers).toEqual(['Type', 'Value in an entry', 'Rules'])
|
||||
expect(anchor.findAll('tbody tr').map((item) => item.find('td').text())).toEqual([
|
||||
'text',
|
||||
'richtext',
|
||||
'number',
|
||||
'boolean',
|
||||
'date',
|
||||
'select',
|
||||
'media',
|
||||
'relation',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the sample field object above the routes', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const group = section(wrapper, 'ctypes')
|
||||
|
||||
expect(group.get('[role="tablist"]').text()).toBe('Field object')
|
||||
const sample = JSON.parse(group.get('pre').text())
|
||||
expect(sample).toEqual({
|
||||
key: 'capacity',
|
||||
label: 'Capacity',
|
||||
type: 'number',
|
||||
required: true,
|
||||
unique: false,
|
||||
default: null,
|
||||
min: 1,
|
||||
max: 5000,
|
||||
options: null,
|
||||
relation: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('draws the PUT badge with the PATCH colours', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const classesOf = (route: string) =>
|
||||
row(wrapper, route)
|
||||
.get('span.font-mono')
|
||||
.element.parentElement!.querySelector('[class*="h-5.5"]')!.className
|
||||
expect(classesOf('PUT /v1/content-type/{key}')).toBe(classesOf('PATCH /v1/media/{id}'))
|
||||
expect(classesOf('PUT /v1/content-type/{key}')).toContain('text-warning')
|
||||
})
|
||||
|
||||
it('makes no /v1 request for a session without the owner or admin role', async () => {
|
||||
@@ -370,16 +505,17 @@ describe('Authentication', () => {
|
||||
})
|
||||
|
||||
describe('Session only', () => {
|
||||
it('renders the banner, 25 rows without expand controls and the 403 chips', async () => {
|
||||
it('renders the banner, 37 rows without expand controls and the 403 chips', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const session = section(wrapper, 'session')
|
||||
|
||||
expect(session.text()).toContain('25 routes')
|
||||
expect(session.get('h2').text()).toBe('Session only')
|
||||
expect(session.text()).toContain('37 routes, not available to API keys')
|
||||
expect(session.text()).toContain(
|
||||
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
|
||||
)
|
||||
const rows = session.findAll('[data-session-row]')
|
||||
expect(rows).toHaveLength(25)
|
||||
expect(rows).toHaveLength(37)
|
||||
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
|
||||
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
|
||||
|
||||
@@ -389,8 +525,12 @@ describe('Session only', () => {
|
||||
'POST /v1/system/update/check',
|
||||
'POST /v1/system/update/download',
|
||||
'POST /v1/system/update/restart',
|
||||
'PATCH /v1/settings',
|
||||
'POST /v1/auth/verify',
|
||||
'DELETE /v1/auth/register',
|
||||
'GET /v1/api-key',
|
||||
'POST /v1/api-key',
|
||||
'DELETE /v1/api-key/{id}',
|
||||
'POST /v1/site/',
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
@@ -399,13 +539,21 @@ describe('Session only', () => {
|
||||
'PATCH /v1/author/{id}',
|
||||
'DELETE /v1/author/{id}',
|
||||
'POST /v1/author/me',
|
||||
'PATCH /v1/settings',
|
||||
'GET /v1/image-variants/',
|
||||
'POST /v1/image-variants/',
|
||||
'PUT /v1/image-variants/{name}',
|
||||
'PATCH /v1/image-variants/{name}',
|
||||
'DELETE /v1/image-variants/{name}',
|
||||
'POST /v1/image-variants/{name}/purge',
|
||||
'GET /v1/image-variants/cache',
|
||||
'POST /v1/image-variants/cache/purge',
|
||||
'GET /v1/media/{id}/variants',
|
||||
'POST /v1/media/{id}/variants',
|
||||
'POST /v1/media/{id}/variants/{name}',
|
||||
'DELETE /v1/media/{id}/variants/{name}',
|
||||
'GET /v1/activity',
|
||||
'GET /v1/activity/summary',
|
||||
'GET /v1/activity/export',
|
||||
'GET /v1/api-key',
|
||||
'POST /v1/api-key',
|
||||
'DELETE /v1/api-key/{id}',
|
||||
])
|
||||
chipRows.forEach((item) =>
|
||||
expect(item.find('svg, [class*="lucide-lock"]').exists()).toBe(true),
|
||||
@@ -432,13 +580,24 @@ describe('Session only', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', async () => {
|
||||
it('lists the six sub-groups in order and every /v1/system route in System', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const session = section(wrapper, 'session')
|
||||
|
||||
expect(session.text()).toContain('System · owner only')
|
||||
expect(session.text()).toContain('Sign in and account')
|
||||
expect(session.text()).toContain('API key management')
|
||||
const titles = [
|
||||
'System · owner only',
|
||||
'Sign in and account',
|
||||
'API key management',
|
||||
'Sites and authors writes',
|
||||
'Image variants',
|
||||
'Activity',
|
||||
]
|
||||
const text = session.text()
|
||||
const positions = titles.map((title) => text.indexOf(title))
|
||||
expect(positions.every((position) => position > -1)).toBe(true)
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
||||
const systemGroup = session.findAll('[data-session-row]').slice(0, 5).map(rowRoute)
|
||||
expect(systemGroup.filter((route) => route.includes('/v1/system'))).toHaveLength(4)
|
||||
const rowLabels = wrapper
|
||||
.findAll('button[aria-expanded]')
|
||||
.map((button) => button.attributes('aria-label'))
|
||||
@@ -483,6 +642,7 @@ describe('Errors', () => {
|
||||
'403',
|
||||
'404',
|
||||
'409',
|
||||
'422',
|
||||
'429',
|
||||
'500',
|
||||
])
|
||||
@@ -492,6 +652,7 @@ describe('Errors', () => {
|
||||
'The expiry must be a date in the future.',
|
||||
'You must be logged in to access this resource.',
|
||||
'This action needs a real session.',
|
||||
'Only owners and admins can do this.',
|
||||
'Only the owner can do this.',
|
||||
'You must verify your account to access this resource.',
|
||||
'Article not found',
|
||||
@@ -500,6 +661,8 @@ describe('Errors', () => {
|
||||
'API key not found',
|
||||
"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.',
|
||||
'Too many requests. Please try again later.',
|
||||
'Internal server error',
|
||||
]) {
|
||||
@@ -516,6 +679,69 @@ describe('Errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Errors table rows of the custom content types round', () => {
|
||||
function rowOf(wrapper: VueWrapper, status: string) {
|
||||
return section(wrapper, 'errors')
|
||||
.findAll('tbody tr')
|
||||
.find((item) => item.find('td').text() === status)!
|
||||
}
|
||||
|
||||
function codes(item: ReturnType<VueWrapper['get']>) {
|
||||
return item.findAll('td:nth-child(3) code').map((code) => code.text())
|
||||
}
|
||||
|
||||
it('lists the four 403 texts in order and keeps the SEO defaults note', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const forbidden = rowOf(wrapper, '403')
|
||||
const text = forbidden.find('td:nth-child(3)').text()
|
||||
|
||||
const order = [
|
||||
'This action needs a real session.',
|
||||
'Only owners and admins can do this.',
|
||||
'Only the owner can do this.',
|
||||
'You must verify your account to access this resource.',
|
||||
].map((message) => text.indexOf(message))
|
||||
expect(order.every((position) => position > -1)).toBe(true)
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b))
|
||||
expect(forbidden.text()).toContain(
|
||||
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the new codes to the 404, 409 and 422 rows and explains code style', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(codes(rowOf(wrapper, '404'))).toEqual([
|
||||
'content_type_not_found',
|
||||
'entry_not_found',
|
||||
'builtin_type_not_found',
|
||||
])
|
||||
expect(rowOf(wrapper, '404').text()).toContain(
|
||||
'Text in code style is the literal body of the response, not a sentence.',
|
||||
)
|
||||
expect(codes(rowOf(wrapper, '409'))).toEqual([
|
||||
'content_type_key_taken',
|
||||
'content_type_has_entries',
|
||||
'content_type_referenced',
|
||||
'schema_incompatible',
|
||||
'invalid_entry: <reason>',
|
||||
'slug_taken',
|
||||
'invalid_entry: <reason>',
|
||||
])
|
||||
expect(rowOf(wrapper, '409').text()).toContain('only when a unique field clashes')
|
||||
expect(codes(rowOf(wrapper, '422'))).toContain('invalid_entry: <reason>')
|
||||
expect(rowOf(wrapper, '422').text()).toContain(
|
||||
'invalid_entry: capacity must be at most 5000',
|
||||
)
|
||||
})
|
||||
|
||||
it('no longer says there is no 409 or 422 row', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(section(wrapper, 'errors').text()).not.toMatch(/no 409|409 or 422 row|no .*422/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('scrolls to the anchor and marks the index item current', async () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
@@ -673,3 +899,313 @@ describe('CodeSnippet migration', () => {
|
||||
expect(importers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expanded cards of the custom content types round', () => {
|
||||
const MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const ID_FORMAT =
|
||||
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
|
||||
|
||||
it.each([
|
||||
[
|
||||
'POST /v1/content-type/',
|
||||
['key', 'name', 'icon', 'fields'],
|
||||
['400', '401', 'Only owners and admins can do this.', 'content_type_key_taken'],
|
||||
],
|
||||
[
|
||||
'PUT /v1/content-type/{key}',
|
||||
['name', 'icon', 'fields'],
|
||||
['400', '401', '403', 'content_type_not_found', 'schema_incompatible'],
|
||||
],
|
||||
[
|
||||
'DELETE /v1/content-type/{key}',
|
||||
[],
|
||||
[
|
||||
'401',
|
||||
'403',
|
||||
'content_type_not_found',
|
||||
'content_type_has_entries',
|
||||
'content_type_referenced',
|
||||
],
|
||||
],
|
||||
[
|
||||
'GET /v1/content-type/{key}/entry',
|
||||
['page', 'size', 'sort'],
|
||||
['Invalid page, size or sort.', '401', 'content_type_not_found'],
|
||||
],
|
||||
[
|
||||
'POST /v1/content-type/{key}/entry',
|
||||
['data'],
|
||||
[
|
||||
'400',
|
||||
'401',
|
||||
'content_type_not_found',
|
||||
'invalid_entry: title must be unique',
|
||||
'invalid_entry: capacity must be at most 5000',
|
||||
],
|
||||
],
|
||||
[
|
||||
'GET /v1/content-type/{key}/entry/{id}',
|
||||
[],
|
||||
[ID_FORMAT, '401', 'entry_not_found', 'content_type_not_found'],
|
||||
],
|
||||
[
|
||||
'PUT /v1/content-type/{key}/entry/{id}',
|
||||
['data'],
|
||||
[
|
||||
'400',
|
||||
'401',
|
||||
'entry_not_found',
|
||||
'content_type_not_found',
|
||||
'invalid_entry: title must be unique',
|
||||
'invalid_entry: capacity must be at most 5000',
|
||||
],
|
||||
],
|
||||
['GET /v1/builtin-type/{key}', [], ['401', '403', 'builtin_type_not_found']],
|
||||
])('%s shows its fields and its errors', async (route, fields, errors) => {
|
||||
const wrapper = await mountIntegration()
|
||||
const opened = await expand(wrapper, route)
|
||||
const text = opened.text()
|
||||
|
||||
if (fields.length === 0) {
|
||||
expect(text).toContain('No request body.')
|
||||
}
|
||||
for (const name of fields) {
|
||||
expect(opened.findAll('td').map((cell) => cell.text())).toContain(name)
|
||||
}
|
||||
for (const error of errors) {
|
||||
expect(text).toContain(error)
|
||||
}
|
||||
const literalCodes = opened.findAll('[title] code').map((code) => code.text())
|
||||
for (const error of errors.filter((item) => /^[a-z_]+(: .*)?$/.test(item))) {
|
||||
expect(literalCodes).toContain(error)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the 400 sentence and the schema_incompatible tail as captions', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const update = await expand(wrapper, 'PUT /v1/content-type/{key}')
|
||||
const create = await expand(wrapper, 'POST /v1/content-type/')
|
||||
|
||||
expect(update.text()).toContain(
|
||||
"Some existing entries don't fit the new fields. Nothing was changed.",
|
||||
)
|
||||
expect(create.text()).toContain(MISMATCH)
|
||||
})
|
||||
|
||||
it('explains the request table of the entry data', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const created = await expand(wrapper, 'POST /v1/content-type/{key}/entry')
|
||||
|
||||
expect(created.text()).toContain(
|
||||
"Keys are the type's field keys. For events: title, starts_on, capacity, online, category and cover.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Response samples follow the real API', () => {
|
||||
async function response(route: string) {
|
||||
const wrapper = await mountIntegration()
|
||||
const opened = await expand(wrapper, route)
|
||||
await activateTab(opened.element as HTMLElement, 'Response')
|
||||
return { opened, body: opened.get('pre').text() }
|
||||
}
|
||||
|
||||
it('answers the entries list with items, total, page and size', async () => {
|
||||
const { body } = await response('GET /v1/content-type/{key}/entry')
|
||||
|
||||
expect(Object.keys(JSON.parse(body))).toEqual(['items', 'total', 'page', 'size'])
|
||||
})
|
||||
|
||||
it('gives an entry its keys and a media object keyed by field key', async () => {
|
||||
const { body } = await response('GET /v1/content-type/{key}/entry/{id}')
|
||||
const entry = JSON.parse(body)
|
||||
|
||||
expect(Object.keys(entry)).toEqual([
|
||||
'id',
|
||||
'content_type',
|
||||
'data',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'media',
|
||||
'relations',
|
||||
])
|
||||
expect(Object.keys(entry.media)).toEqual(['cover'])
|
||||
})
|
||||
|
||||
it('gives a built-in type only key, name and fields', async () => {
|
||||
const { body } = await response('GET /v1/builtin-type/{key}')
|
||||
|
||||
expect(Object.keys(JSON.parse(body))).toEqual(['key', 'name', 'fields'])
|
||||
expect(body).not.toContain('"icon"')
|
||||
})
|
||||
|
||||
it('gives the purge exactly the seven real keys and says what failed_groups counts', async () => {
|
||||
const { opened, body } = await response('POST /v1/media/purge')
|
||||
|
||||
expect(Object.keys(JSON.parse(body))).toEqual([
|
||||
'groups_total',
|
||||
'groups_purged',
|
||||
'merged',
|
||||
'articles_updated',
|
||||
'sites_updated',
|
||||
'authors_updated',
|
||||
'failed_groups',
|
||||
])
|
||||
expect(body).not.toContain('articlesUpdated')
|
||||
expect(body).not.toContain('"count"')
|
||||
expect(opened.text()).toContain('fingerprint')
|
||||
expect(opened.text()).toContain(
|
||||
'failed_groups counts groups that could not be purged; the others were still purged.',
|
||||
)
|
||||
})
|
||||
|
||||
it('names the purge settings keys', async () => {
|
||||
const { body } = await response('GET /v1/media/purge-settings')
|
||||
|
||||
for (const key of [
|
||||
'auto_purge_enabled',
|
||||
'auto_purge_interval_days',
|
||||
'last_purge_at',
|
||||
'last_purge_merged',
|
||||
]) {
|
||||
expect(body).toContain(`"${key}"`)
|
||||
}
|
||||
const patch = await expand(await mountIntegration(), 'PATCH /v1/media/purge-settings')
|
||||
expect(patch.text()).toContain('1 to 365')
|
||||
})
|
||||
|
||||
it.each(['POST /v1/content-type/', 'POST /v1/content-type/{key}/entry'])(
|
||||
'shows %s as the JSON body with no status line',
|
||||
async (route) => {
|
||||
const { body } = await response(route)
|
||||
|
||||
expect(body.startsWith('{')).toBe(true)
|
||||
expect(body).not.toMatch(/^HTTP\/|^201/)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Paged lists', () => {
|
||||
it('lists page and size on GET /v1/media/ with the 400 on a bad query', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const list = await expand(wrapper, 'GET /v1/media/')
|
||||
|
||||
expect(list.text()).toContain('Query parameters')
|
||||
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
|
||||
expect(list.text()).toContain('The media query is invalid.')
|
||||
})
|
||||
|
||||
it('lists page and size on GET /v1/author/ and answers an object', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const list = await expand(wrapper, 'GET /v1/author/')
|
||||
await activateTab(list.element as HTMLElement, 'Response')
|
||||
|
||||
const cells = list.findAll('td').map((cell) => cell.text())
|
||||
expect(cells).toContain('page')
|
||||
expect(cells).toContain('size')
|
||||
expect(Object.keys(JSON.parse(list.get('pre').text()))).toEqual([
|
||||
'items',
|
||||
'total',
|
||||
'page',
|
||||
'size',
|
||||
])
|
||||
})
|
||||
|
||||
it('opens GET /v1/site/ with its sites', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const list = await expand(wrapper, 'GET /v1/site/')
|
||||
|
||||
expect(list.text()).toContain('Verified user')
|
||||
expect(list.text()).toContain('Query parameters')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authentication copy of the custom content types round', () => {
|
||||
it('says a key manages content types and entries and what happens to a lesser creator', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
|
||||
)
|
||||
const auth = section(wrapper, 'auth').text()
|
||||
expect(auth).toContain(
|
||||
"Requests act as the user who created the key, with that user's role (owner or admin).",
|
||||
)
|
||||
expect(auth).toContain(
|
||||
'Owner and admin routes answer 403 to a key whose creator is neither.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('9j Navigation, scroll-spy on Content types', () => {
|
||||
function scrollerHost() {
|
||||
const host = document.createElement('div')
|
||||
host.style.overflowY = 'auto'
|
||||
document.body.appendChild(host)
|
||||
return host
|
||||
}
|
||||
|
||||
function stubOffsets(host: HTMLElement) {
|
||||
const order = ['auth', 'articles', 'seo', 'ctypes']
|
||||
return vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function (this: HTMLElement) {
|
||||
const anchor = this.getAttribute('data-anchor') ?? ''
|
||||
const top =
|
||||
this === host
|
||||
? 0
|
||||
: order.includes(anchor)
|
||||
? order.indexOf(anchor) * 40 - 80
|
||||
: 900
|
||||
return {
|
||||
top,
|
||||
bottom: top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('marks Content types in the index and on the Jump to select', async () => {
|
||||
const host = scrollerHost()
|
||||
const wrapper = await mountIntegration(host)
|
||||
const rect = stubOffsets(host)
|
||||
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
expect(index.findAll('button[aria-current="true"]').map((item) => item.text())).toEqual([
|
||||
'Content types',
|
||||
])
|
||||
expect(wrapper.get('button[aria-label="Jump to section"]').text()).toContain(
|
||||
'Content types',
|
||||
)
|
||||
rect.mockRestore()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('marks Content types in the open Jump to list', async () => {
|
||||
const host = scrollerHost()
|
||||
const wrapper = await mountIntegration(host)
|
||||
const rect = stubOffsets(host)
|
||||
host.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('button[aria-label="Jump to section"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const options = Array.from(document.body.querySelectorAll('[role="option"]'))
|
||||
const marked = options.filter((option) => option.getAttribute('aria-selected') === 'true')
|
||||
expect(marked.map((option) => option.textContent?.trim())).toEqual(['Content types'])
|
||||
rect.mockRestore()
|
||||
host.remove()
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@ describe('Search and sharing group', () => {
|
||||
.map((item) => item.props('endpoint').path),
|
||||
).toContain('/v1/article/{id}/seo/check')
|
||||
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
expect(section(wrapper, 'sites').text()).toContain('2 routes')
|
||||
})
|
||||
|
||||
it('describes the resolved SEO object and shows the real error rows', async () => {
|
||||
@@ -163,12 +163,14 @@ describe('Search and sharing group', () => {
|
||||
'Authentication',
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
'Content types',
|
||||
'Entries',
|
||||
'Built-in types',
|
||||
'Media',
|
||||
'Sites and authors',
|
||||
'Images and variants',
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
'Images and variants',
|
||||
'Session only',
|
||||
'Errors',
|
||||
'Notes',
|
||||
@@ -185,11 +187,13 @@ describe('Search and sharing group', () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
|
||||
expect(forbidden.messages).toHaveLength(3)
|
||||
expect(forbidden.messages).toHaveLength(4)
|
||||
expect(forbidden.note).toBe(NOTE)
|
||||
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
|
||||
expect(notes).toEqual([NOTE, NOTE])
|
||||
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
|
||||
expect(notes.filter((item) => item === NOTE)).toHaveLength(2)
|
||||
expect(ERROR_ROWS.filter((row) => row.note).map((row) => row.status)).toEqual([
|
||||
403, 404, 409, 422,
|
||||
])
|
||||
})
|
||||
|
||||
it('makes no /v1 request when opening and expanding the new cards', async () => {
|
||||
|
||||
Reference in new issue
Block a user