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 () => {
|
||||
|
||||
@@ -242,6 +242,16 @@ const extraSources: Record<string, Source[]> = {
|
||||
'8f. Search: session-only results',
|
||||
'8g. Search: no results',
|
||||
'8h. Search: after selecting an endpoint',
|
||||
'9a. Content types: create, curl',
|
||||
'9b. Entries: list, Response tab',
|
||||
'9c. Entries: replace, 422 and 409',
|
||||
'9d. Errors: 404, 409 and 422 rows',
|
||||
'9e. Session only: 6 sub-groups',
|
||||
'9f. Search: "entry"',
|
||||
'9g. Search: "capacity" (body field)',
|
||||
'9h. Search: "schema_incompatible" (error)',
|
||||
'9i. Search: "content type" (sections)',
|
||||
'9j. Navigation: scroll-spy on Content types',
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
|
||||
+105
-2
@@ -1285,6 +1285,40 @@ async function selectIntegrationMerge(page: Page) {
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
function integrationRowName(route: string) {
|
||||
const escaped = route.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
|
||||
return new RegExp(`^${escaped}\\. `)
|
||||
}
|
||||
|
||||
function openIntegrationCard(route: string, anchor: string, tab?: string) {
|
||||
return async (page: Page) => {
|
||||
await page.getByRole('button', { name: integrationRowName(route) }).click()
|
||||
await page
|
||||
.locator(`[data-anchor="${anchor}"]`)
|
||||
.getByText('Errors', { exact: true })
|
||||
.waitFor()
|
||||
await page.waitForTimeout(600)
|
||||
if (tab) {
|
||||
await page
|
||||
.locator(`[data-anchor="${anchor}"]`)
|
||||
.getByRole('tab', { name: tab, exact: true })
|
||||
.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openIntegrationCardAt(
|
||||
route: string,
|
||||
card: string,
|
||||
scroll: { anchor: string; desktop: number; mobile: number },
|
||||
tab?: string,
|
||||
): VisualState['steps'] {
|
||||
return async (page) => {
|
||||
await openIntegrationCard(route, `ep-${card}`, tab)(page)
|
||||
await scrollToAnchor(page, scroll.anchor, scroll)
|
||||
}
|
||||
}
|
||||
|
||||
function integrationImagesState(
|
||||
list: ApiMock | null,
|
||||
overrides: Partial<VisualState> & { session?: ApiMock } = {},
|
||||
@@ -8710,7 +8744,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
}),
|
||||
'6. Errors': integrationState({
|
||||
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
|
||||
steps: (page) => scrollToAnchor(page, 'errors', { desktop: -122, mobile: 55 }),
|
||||
exception: {
|
||||
maxPercent: { desktop: 16, mobile: 22 },
|
||||
reason: 'The board lacks the 409 row, the "Message" heading and the Sites and Authors contents entries',
|
||||
@@ -8738,6 +8772,75 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
}),
|
||||
'9a. Content types: create, curl': integrationState({
|
||||
steps: async (page) => {
|
||||
const mobile = (page.viewportSize()?.width ?? 0) < desktopMinWidth
|
||||
await openIntegrationCard('POST /v1/content-type/', 'ep-ct-create', 'curl')(page)
|
||||
await scrollToAnchor(page, mobile ? 'ep-ct-create' : 'ct-fields', {
|
||||
desktop: -30,
|
||||
mobile: 88,
|
||||
})
|
||||
},
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 45 },
|
||||
reason: 'The board omits the empty "Query parameters" and "Request body" blocks that the card always draws, and the mobile field table has four columns where the board stacks the rule under the name',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
}),
|
||||
'9b. Entries: list, Response tab': integrationState({
|
||||
steps: openIntegrationCardAt(
|
||||
'GET /v1/content-type/{key}/entry',
|
||||
'en-list',
|
||||
{ anchor: 'ep-en-list', desktop: -44, mobile: 88 },
|
||||
'Response',
|
||||
),
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 41 },
|
||||
reason: 'The board omits the empty "Query parameters" and "Request body" blocks that the card always draws, and the mobile field table has four columns where the board stacks the rule under the name',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
}),
|
||||
'9c. Entries: replace, 422 and 409': integrationState({
|
||||
steps: openIntegrationCardAt('PUT /v1/content-type/{key}/entry/{id}', 'en-update', {
|
||||
anchor: 'ep-en-update',
|
||||
desktop: -108,
|
||||
mobile: 88,
|
||||
}),
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 42 },
|
||||
reason: 'The board omits the empty "Query parameters" and "Request body" blocks that the card always draws, and the mobile field table has four columns where the board stacks the rule under the name',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
}),
|
||||
'9d. Errors: 404, 409 and 422 rows': integrationState({
|
||||
steps: (page) => scrollToAnchor(page, 'errors', { desktop: -122, mobile: 55 }),
|
||||
exception: {
|
||||
maxPercent: { desktop: 15, mobile: 22 },
|
||||
reason: 'The app joins the error messages with " / " and puts the note under them, where the board writes prose with the note in another position',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
}),
|
||||
'9e. Session only: 6 sub-groups': integrationState({
|
||||
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: -107 }),
|
||||
}),
|
||||
'9f. Search: "entry"': integrationState({ steps: searchingIntegration('entry') }),
|
||||
'9g. Search: "capacity" (body field)': integrationState({
|
||||
steps: searchingIntegration('capacity'),
|
||||
}),
|
||||
'9h. Search: "schema_incompatible" (error)': integrationState({
|
||||
steps: searchingIntegration('schema_incompatible'),
|
||||
}),
|
||||
'9i. Search: "content type" (sections)': integrationState({
|
||||
steps: searchingIntegration('content type'),
|
||||
}),
|
||||
'9j. Navigation: scroll-spy on Content types': integrationState({
|
||||
steps: async (page) => {
|
||||
await scrollToAnchor(page, 'ctypes', { desktop: -5, mobile: -3 })
|
||||
if ((page.viewportSize()?.width ?? 0) < desktopMinWidth) {
|
||||
await page.getByRole('button', { name: 'Jump to section' }).click()
|
||||
}
|
||||
},
|
||||
}),
|
||||
'I1 · Many variants': integrationImagesState(integrationManyVariants),
|
||||
'I2 · One variant': integrationImagesState(
|
||||
integrationVariantsList(integrationVariantRows.slice(0, 1)),
|
||||
@@ -8794,7 +8897,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
}),
|
||||
'7. Navigation': integrationState({
|
||||
steps: async (page) => {
|
||||
await scrollToAnchor(page, 'media', { desktop: -5, mobile: -3 })
|
||||
await scrollToAnchor(page, 'media', { desktop: -81, mobile: -3 })
|
||||
if ((page.viewportSize()?.width ?? 0) < 1024) {
|
||||
await page.getByRole('button', { name: 'Jump to section' }).click()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user