feat: integration page (PART 3/3)

This commit is contained in:
fsylvain committed 2026-10-05 02:03:24 +02:00
1 parent 514a082c96
commit acd392c29d
20 files changed
+3115 -409

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ApiField } from '@/data/apiCatalogue'
defineProps<{
fields: ApiField[]
}>()
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
accessorKey: 'name',
get header() {
return t('Name')
},
...fixed('w-33'),
},
{
accessorKey: 'type',
get header() {
return t('Type')
},
...fixed('w-21'),
},
{
accessorKey: 'required',
get header() {
return t('Required')
},
...fixed('w-21'),
},
{
accessorKey: 'rule',
get header() {
return t('Rule')
},
},
])
</script>
<template>
<UTable
:data="fields"
:columns="columns"
:ui="{
root: 'border border-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 #name-cell="{ row }">
<span class="font-mono text-xs font-medium break-words text-highlighted">{{
row.original.name
}}</span>
</template>
<template #type-cell="{ row }">
<span class="font-mono text-xs">{{ row.original.type }}</span>
</template>
<template #required-cell="{ row }">
{{ row.original.required ? t('Yes') : t('No') }}
</template>
<template #rule-cell="{ row }">
<span class="text-pretty">{{ row.original.rule }}</span>
</template>
</UTable>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HttpMethod } from '@/data/apiCatalogue'
defineProps<{
method: HttpMethod
path: string
description: string
}>()
</script>
<template>
<div
class="flex min-h-9 flex-col gap-0.5 px-3.5 py-2 lg:grid lg:grid-cols-[280px_minmax(0,1fr)_auto] lg:items-center lg:gap-4 lg:py-0"
>
<span class="flex min-w-0 items-center gap-2.5">
<span class="w-14 shrink-0 font-mono text-xs font-medium text-muted">{{ method }}</span>
<span class="min-w-0 truncate font-mono text-sm leading-4.25 text-toned">{{
path
}}</span>
</span>
<span class="text-muted">{{ description }}</span>
<slot />
</div>
</template>
+4 -4
View File
@@ -30,7 +30,7 @@ function onCopy() {
<template>
<div class="bg-elevated">
<div class="flex h-8 items-center justify-between border-b border-default pr-2 pl-4">
<div class="flex h-8 items-center justify-between border-b border-default pr-1 pl-3">
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
label ?? lang
}}</span>
@@ -41,7 +41,7 @@ function onCopy() {
size="xs"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-1.5 font-medium text-toned"
class="gap-1.5 px-2 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
@@ -51,13 +51,13 @@ function onCopy() {
<div
v-if="highlightedHtml"
tabindex="0"
class="shiki-block overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre"
class="shiki-block overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre"
v-html="highlightedHtml"
/>
<pre
v-else
tabindex="0"
class="overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre text-default"
class="overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre text-default"
>{{ code }}</pre
>
</div>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { splitInlineCode } from '@/lib/apiReference'
defineProps<{
text: string
}>()
</script>
<template>
<template v-for="(part, index) in splitInlineCode(text)" :key="index">
<code v-if="part.code" class="font-mono text-xs text-highlighted">{{ part.value }}</code>
<template v-else>{{ part.value }}</template>
</template>
</template>
+154 -54
View File
@@ -1,18 +1,42 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, translate } from '@/i18n'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import ApiFieldTable from '@/components/ApiFieldTable.vue'
import CodeBlock from '@/components/CodeBlock.vue'
import {
RATE_LIMIT_SENTENCE,
NO_CONTENT_RESPONSE,
endpointExamples,
type ApiEndpoint,
} from '@/data/apiCatalogue'
import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
defineProps<{
method: 'GET'
path: string
description: string
examples: { fetch: string; curl: string; response: string }
const props = defineProps<{
endpoint: ApiEndpoint
origin: string
}>()
const open = ref(false)
const activeTab = ref('fetch')
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
const rowLabel = computed(
() => `${props.endpoint.method} ${props.endpoint.path}. ${props.endpoint.description}`,
)
const bodyTitle = computed(() => {
if (props.endpoint.bodyKind === 'multipart') {
return t('Request body · multipart/form-data, 1 file field')
}
return props.endpoint.bodyKind === 'json' ? t('Request body · JSON') : t('Request body')
})
const responseLabel = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'HTTP' : 'JSON',
)
const responseLang = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'text' : 'json',
)
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
@@ -26,55 +50,131 @@ const tabItems = [
</script>
<template>
<UCard
:ui="{
root: 'border border-default ring-0 divide-default',
header: 'p-4 sm:p-4',
body: 'p-0 sm:p-0',
}"
<UCollapsible
v-model:open="open"
:data-anchor="endpointAnchor(endpoint.id)"
class="scroll-mt-22 lg:scroll-mt-14 border-t border-default first:border-t-0"
:ui="{ content: 'border-t border-muted' }"
>
<template #header>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2.5">
<UBadge :label="method" color="neutral" variant="solid" class="font-mono" />
<span class="font-mono text-base font-medium text-highlighted">{{ path }}</span>
<button
type="button"
:aria-label="rowLabel"
class="flex min-h-11 w-full cursor-pointer flex-col items-stretch gap-1.5 px-3 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary lg:grid lg:grid-cols-[300px_minmax(0,1fr)_auto_16px] lg:items-center lg:gap-4 lg:px-4 lg:py-0"
:class="open ? 'bg-muted' : ''"
>
<span class="flex min-w-0 items-center gap-2.5">
<UBadge
:label="endpoint.method"
color="neutral"
variant="outline"
:class="METHOD_CLASS[endpoint.method]"
/>
<span class="min-w-0 truncate font-mono text-sm font-medium text-highlighted">{{
endpoint.path
}}</span>
<span class="flex-1 lg:hidden" />
<UIcon
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="size-4 shrink-0 text-muted lg:hidden"
/>
</span>
<span class="min-w-0 text-toned lg:truncate">{{ endpoint.description }}</span>
<span class="flex flex-wrap gap-1.5">
<UBadge
:label="translate(endpoint.access)"
color="neutral"
variant="outline"
:class="
endpoint.access === 'Owner only' ? CHIP_CLASS.strong : CHIP_CLASS.neutral
"
/>
<UBadge
:label="t('API key OK')"
icon="i-lucide-check"
color="success"
variant="soft"
:class="CHIP_CLASS.success"
:ui="{ leadingIcon: 'size-3' }"
/>
</span>
<UIcon
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="hidden size-4 text-muted lg:block"
/>
</button>
<template #content>
<div class="flex flex-col gap-3.5 px-3 pt-2.5 pb-4 lg:px-4">
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ t('Query parameters') }}</span>
<ApiFieldTable v-if="endpoint.query.length > 0" :fields="endpoint.query" />
<span v-else class="text-muted">{{ t('No query parameters.') }}</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ bodyTitle }}</span>
<ApiFieldTable v-if="endpoint.body.length > 0" :fields="endpoint.body" />
<span v-else class="text-muted">{{ t('No request body.') }}</span>
</div>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned max-lg:h-11',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<CodeBlock
v-else-if="item.value === 'curl'"
label="Shell"
lang="shell"
:code="examples.curl"
/>
<CodeBlock
v-else
:label="responseLabel"
:lang="responseLang"
:code="examples.response"
/>
</template>
</UTabs>
<div v-if="endpoint.rateLimited" class="flex gap-2.5 text-toned">
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-pretty">{{ translate(RATE_LIMIT_SENTENCE) }}</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-semibold text-highlighted">{{ t('Errors') }}</span>
<div
v-for="error in endpoint.errors"
:key="`${error.status}-${error.caption}`"
:title="error.caption"
class="flex items-baseline gap-2.5"
>
<UBadge
:label="String(error.status)"
color="neutral"
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>
</div>
</div>
<span class="text-toned">{{ description }}</span>
</div>
</template>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0',
list: 'gap-5 border-b border-default px-4 py-0 before:hidden',
trigger: 'h-9 grow-0 px-0 text-sm font-normal data-[state=active]:font-medium',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<pre
v-else-if="item.value === 'curl'"
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.curl }}</pre
>
<pre
v-else
tabindex="0"
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
>{{ examples.response }}</pre
>
</template>
</UTabs>
</UCard>
</UCollapsible>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
export interface IndexSection {
id: string
label: string
}
const props = defineProps<{
sections: IndexSection[]
active: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const activeLabel = computed(
() => props.sections.find((section) => section.id === props.active)?.label ?? '',
)
function onJump(id: string) {
emit('select', id)
}
</script>
<template>
<nav
:aria-label="t('On this page')"
class="sticky top-0 hidden w-42 shrink-0 flex-col gap-0.5 self-start border-l border-default lg:flex"
>
<span
class="px-3 pb-1.5 text-xs leading-5 font-medium tracking-wide text-muted uppercase"
>{{ t('On this page') }}</span
>
<button
v-for="section in sections"
:key="section.id"
type="button"
:aria-current="section.id === active ? 'true' : undefined"
class="-ml-px flex h-7 cursor-pointer items-center border-l-2 px-3 text-left hover:text-highlighted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="
section.id === active
? 'border-primary font-medium text-highlighted'
: 'border-transparent text-toned'
"
@click="onJump(section.id)"
>
{{ section.label }}
</button>
</nav>
<div
class="sticky -top-5 z-5 order-first -mx-4 -mt-5 mb-5 border-b border-default bg-default px-4 py-2 lg:hidden"
>
<USelectMenu
:model-value="active"
:items="sections"
value-key="id"
label-key="label"
trailing-icon="i-lucide-chevrons-up-down"
:search-input="false"
:aria-label="t('Jump to section')"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
trailingIcon: 'size-4 text-muted',
content: 'max-h-none w-(--reka-popper-anchor-width) border border-default',
item: 'h-11 px-2.5 text-base text-highlighted',
}"
@update:model-value="onJump($event as string)"
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="text-muted">{{ t('Jump to') }}</span>
<span class="flex-1 truncate text-left font-medium text-highlighted">{{
activeLabel
}}</span>
</span>
</USelectMenu>
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { t, translate } from '@/i18n'
import ApiRouteRow from '@/components/ApiRouteRow.vue'
import type { PublicRow, SessionGroup } from '@/data/apiCatalogue'
import { CHIP_CLASS } from '@/lib/apiReference'
defineProps<{
groups: SessionGroup[]
publicRows: PublicRow[]
}>()
const SUBGROUP_TITLE_CLASS =
'px-3.5 pt-2 pb-1 text-xs font-medium tracking-wide text-muted uppercase'
</script>
<template>
<div class="flex flex-col gap-3">
<div class="border border-dashed border-accented bg-muted">
<div class="flex gap-2.5 px-3.5 py-3 text-pretty text-toned">
<UIcon name="i-lucide-lock" class="mt-0.5 size-4 shrink-0 text-muted" />
<span>{{
t(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
}}</span>
</div>
<div
v-for="group in groups"
:key="group.title"
class="border-t border-dashed border-accented"
>
<div :class="SUBGROUP_TITLE_CLASS">{{ translate(group.title) }}</div>
<ApiRouteRow
v-for="row in group.rows"
:key="`${row.method} ${row.path}`"
:method="row.method"
:path="row.path"
:description="row.description"
data-session-row
>
<UBadge
v-if="row.answers403"
:label="t('Session only')"
icon="i-lucide-lock"
color="neutral"
variant="outline"
:class="[CHIP_CLASS.dashed, 'self-start lg:self-center']"
:ui="{ leadingIcon: 'size-3' }"
/>
</ApiRouteRow>
</div>
</div>
<div class="border border-default bg-default">
<div :class="SUBGROUP_TITLE_CLASS">{{ t('Public auth flow') }}</div>
<ApiRouteRow
v-for="row in publicRows"
:key="`${row.method} ${row.path}`"
:method="row.method"
:path="row.path"
:description="row.description"
data-public-row
>
<UBadge
:label="t('Public')"
color="neutral"
variant="outline"
:class="[CHIP_CLASS.neutral, 'self-start lg:self-center']"
/>
</ApiRouteRow>
</div>
</div>
</template>
+1 -1
View File
@@ -6,7 +6,7 @@ export function useShikiHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ['github-light', 'github-dark'],
langs: ['javascript'],
langs: ['javascript', 'shellscript', 'json'],
})
}
return highlighterPromise
+559
View File
@@ -0,0 +1,559 @@
import { describe, expect, it } from 'vitest'
import {
API_GROUPS,
API_KEY_PLACEHOLDER,
ERROR_ROWS,
LOGIN_FAMILY_CAPTION,
NO_CONTENT_RESPONSE,
OWNER_REQUIRED_MESSAGE,
PUBLIC_AUTH_ROWS,
RATE_LIMIT_SENTENCE,
SESSION_GROUPS,
SESSION_REQUIRED_MESSAGE,
allEndpoints,
endpointExamples,
type ApiEndpoint,
} from '../apiCatalogue'
const ORIGIN = 'https://cms.renew.example'
const EXPECTED_GROUPS: Record<string, string[]> = {
Articles: [
'GET /v1/article/',
'GET /v1/article/{id}',
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
'DELETE /v1/article/{id}',
],
Media: [
'GET /v1/media/',
'POST /v1/media/',
'PATCH /v1/media/{id}',
'DELETE /v1/media/{id}',
'POST /v1/media/merge',
'GET /v1/media/{id}/usage',
],
'Users and invites': [
'GET /v1/user/',
'PATCH /v1/user/me',
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
'GET /v1/auth/me',
],
Settings: ['GET /v1/settings'],
}
const EXPECTED_SESSION: Record<string, string[]> = {
'System · owner only': [
'GET /v1/system/update',
'POST /v1/system/update/check',
'POST /v1/system/update/download',
'POST /v1/system/update/restart',
],
'Sign in and account': [
'POST /v1/auth/login',
'POST /v1/auth/logout',
'POST /v1/auth/register',
'POST /v1/auth/verify',
'DELETE /v1/auth/register',
'POST /v1/auth/password-reset',
],
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
}
const EXPECTED_PUBLIC = [
'GET /v1/',
'GET /v1/auth/registration',
'POST /v1/auth/password-reset/confirm',
'GET /v1/auth/invite',
'POST /v1/auth/invite/accept',
'GET /healthz',
]
const ARTICLE_KEYS = ['id', 'title', 'body', 'body_format', 'is_online', 'created_at', 'updated_at']
const MEDIA_ITEM_KEYS = [
'id',
'filename',
'url',
'size',
'content_type',
'width',
'height',
'sha256',
'uploaded_by',
'created_at',
'used_in',
'duplicate',
]
const USER_LIST_KEYS = [
'id',
'kind',
'username',
'display_name',
'email',
'role',
'status',
'created_at',
]
const INVITE_KEYS = ['id', 'kind', 'email', 'status', 'created_at', 'expires_at']
const ME_KEYS = [
'id',
'username',
'email',
'is_verified',
'created_at',
'updated_at',
'display_name',
'role',
]
const label = (item: { method: string; path: string }) => `${item.method} ${item.path}`
function find(route: string) {
const found = allEndpoints().find((endpoint) => label(endpoint) === route)
if (!found) {
throw new Error(`catalogue has no ${route}`)
}
return found
}
function parsed(endpoint: ApiEndpoint) {
return JSON.parse(endpoint.response)
}
function keysOf(value: unknown) {
return Object.keys(value as Record<string, unknown>).sort()
}
function expectOnlyKeys(value: unknown, allowed: string[]) {
expect(keysOf(value).filter((key) => !allowed.includes(key))).toEqual([])
}
describe('Catalogue routes', () => {
it('holds exactly the 20 key-callable routes in 4 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(20)
})
it('holds the 13 session-only rows in 3 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(13)
})
it('holds the 6 public auth flow rows', () => {
expect(PUBLIC_AUTH_ROWS.map(label)).toEqual(EXPECTED_PUBLIC)
})
it('has no /v1/system route in an expandable group', () => {
expect(allEndpoints().filter((endpoint) => endpoint.path.startsWith('/v1/system'))).toEqual(
[],
)
})
it('gives every route, group, session row and public row a non-blank description', () => {
const described = [
...API_GROUPS.map((group) => [`group ${group.title}`, group.description] as const),
...allEndpoints().map((item) => [label(item), item.description] as const),
...SESSION_GROUPS.flatMap((group) => group.rows).map(
(item) => [label(item), item.description] as const,
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(4 + 20 + 13 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
it.each(Object.values(EXPECTED_GROUPS).flat())(
'describes %s with a description that is not its path',
(route) => {
const item = find(route)
expect(item.description.trim().length, `${route} has no description`).toBeGreaterThan(5)
expect(item.description).not.toContain(item.path)
},
)
})
describe('Catalogue completeness', () => {
it.each(allEndpoints().map((endpoint) => [label(endpoint), endpoint] as const))(
'%s has method, path, chips, tables and examples',
(_route, endpoint) => {
const examples = endpointExamples(endpoint, ORIGIN)
expect(['GET', 'POST', 'PATCH', 'DELETE']).toContain(endpoint.method)
expect(endpoint.path.startsWith('/v1/')).toBe(true)
expect(['Public', 'Verified user', 'Owner only']).toContain(endpoint.access)
expect(examples.fetch).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(`${ORIGIN}/v1/`)
expect(examples.curl).toContain(
`"${ORIGIN}${endpoint.path.replace('{id}', String(endpoint.pathId))}"`,
)
expect(examples.response).toBe(endpoint.response)
expect(examples.response.trim()).not.toBe('')
expect(endpoint.errors.length).toBeGreaterThan(0)
for (const error of endpoint.errors) {
expect(error.caption.trim()).not.toBe('')
}
for (const field of [...endpoint.query, ...endpoint.body]) {
expect(field.name.trim()).not.toBe('')
expect(field.rule.trim()).not.toBe('')
}
},
)
it('gives Public to the two article reads and Owner only to the invite routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Public')
.map(label),
).toEqual(['GET /v1/article/', 'GET /v1/article/{id}'])
expect(
allEndpoints()
.filter((endpoint) => endpoint.access === 'Owner only')
.map(label),
).toEqual([
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
])
})
})
describe('Response samples use the real domain JSON keys', () => {
it('uses the article keys, without images or status', () => {
const single = parsed(find('GET /v1/article/{id}'))
expect(keysOf(single)).toEqual([...ARTICLE_KEYS].sort())
expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list[0], ARTICLE_KEYS)
expect(list[0]).not.toHaveProperty('images')
expect(list[0]).not.toHaveProperty('status')
})
it('returns articles from create, update, publish and unpublish', () => {
for (const route of [
'POST /v1/article/',
'PATCH /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'PATCH /v1/article/{id}/unpublish',
]) {
expect(keysOf(parsed(find(route))), route).toEqual([...ARTICLE_KEYS].sort())
}
expect(parsed(find('PATCH /v1/article/{id}/publish')).is_online).toBe(true)
expect(parsed(find('PATCH /v1/article/{id}/unpublish')).is_online).toBe(false)
})
it('uses items, next_cursor and counts for the media list', () => {
const list = parsed(find('GET /v1/media/'))
expect(keysOf(list)).toEqual(['counts', 'items', 'next_cursor'])
expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS)
})
it('uses media item keys for upload and rename', () => {
expectOnlyKeys(parsed(find('POST /v1/media/')), MEDIA_ITEM_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/media/{id}')), MEDIA_ITEM_KEYS)
})
it('uses articles_updated and merged for the merge', () => {
expect(keysOf(parsed(find('POST /v1/media/merge')))).toEqual(['articles_updated', 'merged'])
})
it('uses id, title and published for usage items', () => {
const usage = parsed(find('GET /v1/media/{id}/usage'))
expect(keysOf(usage[0])).toEqual(['id', 'published', 'title'])
})
it('uses user and invite keys for the user list and invites', () => {
const list = parsed(find('GET /v1/user/'))
expectOnlyKeys(list[0], USER_LIST_KEYS)
expectOnlyKeys(list[1], INVITE_KEYS)
expectOnlyKeys(parsed(find('POST /v1/user/invite')), INVITE_KEYS)
expectOnlyKeys(parsed(find('POST /v1/user/invite/{id}/renew')), INVITE_KEYS)
})
it('uses the public user keys for the profile routes', () => {
expectOnlyKeys(parsed(find('GET /v1/auth/me')), ME_KEYS)
expectOnlyKeys(parsed(find('PATCH /v1/user/me')), ME_KEYS)
})
it('uses version for the settings', () => {
expect(keysOf(parsed(find('GET /v1/settings')))).toEqual(['version'])
})
it('answers 204 for the three deletes without a JSON body', () => {
for (const route of [
'DELETE /v1/article/{id}',
'DELETE /v1/media/{id}',
'DELETE /v1/user/invite/{id}',
]) {
expect(find(route).response, route).toBe(NO_CONTENT_RESPONSE)
}
})
it('parses every other response sample as JSON', () => {
for (const endpoint of allEndpoints().filter(
(item) => item.response !== NO_CONTENT_RESPONSE,
)) {
expect(() => JSON.parse(endpoint.response), label(endpoint)).not.toThrow()
}
})
})
describe('Request bodies and query parameters', () => {
const bodyOf = (route: string) =>
find(route).body.map((field) => [field.name, field.type, field.required])
it('matches the handler validate tags', () => {
expect(bodyOf('POST /v1/article/')).toEqual([
['title', 'string', true],
['body', 'string', true],
])
expect(find('POST /v1/article/').body[0]!.rule).toContain('3 to 200')
expect(find('POST /v1/article/').body[1]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/article/{id}')).toEqual([['body', 'string', true]])
expect(find('PATCH /v1/article/{id}').body[0]!.rule).toContain('10 000')
expect(bodyOf('PATCH /v1/media/{id}')).toEqual([['filename', 'string', true]])
expect(bodyOf('POST /v1/media/merge')).toEqual([
['keep_id', 'number', true],
['merge_ids', 'number[]', true],
])
expect(bodyOf('PATCH /v1/user/me')).toEqual([['display_name', 'string', true]])
expect(find('PATCH /v1/user/me').body[0]!.rule).toContain('60')
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
})
it('uses the multipart field "file" for the upload', () => {
const upload = find('POST /v1/media/')
expect(upload.bodyKind).toBe('multipart')
expect(upload.body.map((field) => field.name)).toEqual(['file'])
})
it('has no request body on reads, publish, unpublish and deletes', () => {
for (const route of [
'GET /v1/article/',
'GET /v1/article/{id}',
'PATCH /v1/article/{id}/publish',
'DELETE /v1/article/{id}',
'GET /v1/media/',
'GET /v1/settings',
]) {
expect(find(route).body, route).toEqual([])
}
})
it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(
endpoint.query.map((param) => param.name),
label(endpoint),
).toEqual(['format'])
}
})
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q',
'hash',
'cursor',
'filter',
'sort',
'limit',
])
expect(find('GET /v1/media/').query.find((param) => param.name === 'limit')!.type).toBe(
'number',
)
expect(find('POST /v1/media/').query).toEqual([])
expect(find('GET /v1/settings').query).toEqual([])
})
})
describe('Owner-only invite routes', () => {
const inviteRoutes = [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]
it.each(inviteRoutes)('%s is owner only, rate limited, and lists the 403', (route) => {
const endpoint = find(route)
expect(endpoint.access).toBe('Owner only')
expect(endpoint.rateLimited).toBe(true)
expect(endpoint.errors).toContainEqual({ status: 403, caption: OWNER_REQUIRED_MESSAGE })
expect(RATE_LIMIT_SENTENCE).toContain('rate limited')
expect(RATE_LIMIT_SENTENCE).toContain('429')
expect(RATE_LIMIT_SENTENCE.replace('429', '')).not.toMatch(/\d/)
})
it('does not rate limit other routes', () => {
expect(
allEndpoints()
.filter((endpoint) => endpoint.rateLimited)
.map(label),
).toEqual(inviteRoutes)
})
})
describe('Per-endpoint error captions use the real strings', () => {
const captions: [string, number, string][] = [
['POST /v1/media/', 400, 'the image could not be read'],
['POST /v1/media/', 413, 'the file exceeds the maximum upload size'],
['POST /v1/media/', 415, 'the file must be a PNG, JPEG, WEBP, or SVG image'],
['GET /v1/media/', 400, 'The media query is invalid.'],
[
'POST /v1/media/merge',
400,
'Choose an image to keep and at least one other image to merge into it.',
],
[
'POST /v1/media/merge',
409,
'Some of the images to merge changed. Refresh and try again.',
],
['PATCH /v1/media/{id}', 400, 'Enter a filename.'],
['POST /v1/article/', 400, 'Article title must be between 3 and 200 characters.'],
[
'GET /v1/article/{id}',
400,
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).',
],
['PATCH /v1/user/me', 400, 'Display name must be 60 characters or fewer.'],
['POST /v1/user/invite', 409, 'This address already has an account.'],
['POST /v1/user/invite', 409, "There's already a pending invite for this address."],
['POST /v1/user/invite/{id}/renew', 409, 'This invite has already been accepted.'],
['POST /v1/user/invite', 403, 'Only the owner can do this.'],
]
it.each(captions)('%s lists %i "%s"', (route, status, caption) => {
expect(find(route).errors).toContainEqual({ status, caption })
})
it('adds the format error to every article card', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(endpoint.errors, label(endpoint)).toContainEqual({
status: 400,
caption: 'The format must be one of: markdown, html, json.',
})
}
})
it('lists 400 and 401 on POST /v1/article/', () => {
const statuses = find('POST /v1/article/').errors.map((error) => error.status)
expect(statuses).toContain(400)
expect(statuses).toContain(401)
})
})
describe('Session only rows', () => {
const rows = SESSION_GROUPS.flatMap((group) => group.rows)
it('claims 403 only on verify, DELETE register, 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',
'GET /v1/api-key',
'POST /v1/api-key',
'DELETE /v1/api-key/{id}',
])
})
it('captions login, logout, register and password reset without a 403 claim', () => {
const family = rows.filter((row) => !row.answers403)
expect(family.map(label)).toEqual([
'POST /v1/auth/login',
'POST /v1/auth/logout',
'POST /v1/auth/register',
'POST /v1/auth/password-reset',
])
for (const row of family) {
expect(row.description).toContain(LOGIN_FAMILY_CAPTION)
}
})
})
describe('Errors table', () => {
it('lists the real statuses and messages', () => {
expect(ERROR_ROWS.map((row) => [row.status, row.messages])).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.']],
[
403,
[
SESSION_REQUIRED_MESSAGE,
'Only the owner can do this.',
'You must verify your account to access this resource.',
],
],
[
404,
['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
],
[
409,
[
"There's already a pending invite for this address.",
'This invite has already been accepted.',
],
],
[429, ['Too many requests. Please try again later.']],
[500, ['Internal server error']],
])
})
})
describe('Examples', () => {
it('start from the origin and carry no ?format= query', () => {
for (const endpoint of allEndpoints()) {
const examples = endpointExamples(endpoint, ORIGIN)
expect(examples.fetch, label(endpoint)).toContain(`'${ORIGIN}/v1/`)
expect(examples.curl, label(endpoint)).toContain(`"${ORIGIN}/v1/`)
expect(examples.fetch).not.toContain('?format=')
expect(examples.curl).not.toContain('?format=')
}
})
it('carry the Authorization header on non-public routes only', () => {
for (const endpoint of allEndpoints()) {
const examples = endpointExamples(endpoint, ORIGIN)
const header = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
if (endpoint.access === 'Public') {
expect(examples.curl, label(endpoint)).not.toContain('Authorization')
expect(examples.fetch, label(endpoint)).not.toContain('Authorization')
} else {
expect(examples.curl, label(endpoint)).toContain(header)
expect(examples.fetch, label(endpoint)).toContain(
`Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
)
}
}
})
it('send the multipart field "file" for the upload', () => {
const examples = endpointExamples(find('POST /v1/media/'), ORIGIN)
expect(examples.curl).toContain('-F "file=@north-wall.jpg"')
expect(examples.fetch).toContain("form.append('file', file)")
})
it('start with the rcms_ prefix for the key', () => {
expect(API_KEY_PLACEHOLDER.startsWith('rcms_')).toBe(true)
})
})
+756
View File
@@ -0,0 +1,756 @@
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'
export type AccessLevel = 'Public' | 'Verified user' | 'Owner only'
export interface ApiField {
name: string
type: string
required: boolean
rule: string
}
export interface ApiError {
status: number
caption: string
}
export type BodyKind = 'none' | 'json' | 'multipart'
export interface ApiEndpoint {
id: string
method: HttpMethod
path: string
description: string
access: AccessLevel
pathId: number | null
query: ApiField[]
bodyKind: BodyKind
body: ApiField[]
bodySample: Record<string, unknown> | null
errors: ApiError[]
response: string
rateLimited: boolean
}
export interface ApiGroup {
key: string
title: string
description: string
endpoints: ApiEndpoint[]
}
export interface SessionRow {
method: HttpMethod
path: string
description: string
answers403: boolean
}
export interface SessionGroup {
title: string
rows: SessionRow[]
}
export interface PublicRow {
method: HttpMethod
path: string
description: string
}
export interface ErrorRow {
status: number
meaning: string
messages: string[]
}
export interface EndpointExamples {
fetch: string
curl: string
response: string
}
export const API_KEY_PLACEHOLDER = 'rcms_Kf3x9QwL0aBc…'
export const NO_CONTENT_RESPONSE = '204 No Content'
export const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
export const OWNER_REQUIRED_MESSAGE = 'Only the owner can do this.'
export const LOGIN_FAMILY_CAPTION = 'Made for the browser. A key is ignored here, with no 403.'
export const RATE_LIMIT_SENTENCE = 'This route is rate limited. Too many requests answer 429.'
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const UNAUTHORIZED = 'You must be logged in to access this resource.'
const VERIFY_REQUIRED = 'You must verify your account to access this resource.'
const ID_FORMAT =
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).'
const FORMAT_INVALID = 'The format must be one of: markdown, html, json.'
const TOO_MANY_REQUESTS = 'Too many requests. Please try again later.'
const FORMAT_QUERY: ApiField = {
name: 'format',
type: 'string',
required: false,
rule: 'markdown, html or json. Default markdown.',
}
const MEDIA_QUERY: ApiField[] = [
{ name: 'q', type: 'string', required: false, rule: 'Search text.' },
{
name: 'hash',
type: 'string',
required: false,
rule: 'SHA-256 of the content, lowercased.',
},
{
name: 'cursor',
type: 'string',
required: false,
rule: 'The next_cursor of the previous page.',
},
{
name: 'filter',
type: 'string',
required: false,
rule: 'all, unused or duplicates. Default all.',
},
{
name: 'sort',
type: 'string',
required: false,
rule: 'newest, oldest, name or largest. Default newest.',
},
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
]
const json = (value: unknown) => JSON.stringify(value, null, 2)
const ARTICLE_TITLE = 'How we price a bathroom renovation'
const article = (overrides: Record<string, unknown> = {}) => ({
id: 12,
title: ARTICLE_TITLE,
body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.',
body_format: 'markdown',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
...overrides,
})
const mediaItem = (overrides: Record<string, unknown> = {}) => ({
id: 4,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/3f9c2b7e-5d1a-4c8e-9b0f-6a2d4e8c1b73.jpg',
size: 3900000,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9',
uploaded_by: 'marta',
created_at: '2026-09-10T07:00:00Z',
used_in: 2,
duplicate: false,
...overrides,
})
const me = {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
created_at: '2026-01-05T08:30:00Z',
updated_at: '2026-09-10T07:00:00Z',
display_name: 'Marta Alves',
role: 'owner',
}
const invite = (overrides: Record<string, unknown> = {}) => ({
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
expires_at: '2026-09-13T07:00:00Z',
...overrides,
})
const e = (status: number, caption: string): ApiError => ({ status, caption })
const articleErrors = (...own: ApiError[]) => [...own, e(400, FORMAT_INVALID), e(401, UNAUTHORIZED)]
const verified = (errors: ApiError[]) => [...errors, e(403, VERIFY_REQUIRED)]
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = []
function endpoint(
id: string,
method: HttpMethod,
path: string,
description: string,
access: AccessLevel,
errors: ApiError[],
response: string,
extra: Partial<ApiEndpoint> = {},
): ApiEndpoint {
return {
id,
method,
path,
description,
access,
pathId: path.includes('{id}') ? 12 : null,
query: none,
bodyKind: 'none',
body: none,
bodySample: null,
errors,
response,
rateLimited: false,
...extra,
}
}
const ownerOnly = (
id: string,
method: HttpMethod,
path: string,
description: string,
errors: ApiError[],
response: string,
extra: Partial<ApiEndpoint> = {},
) =>
endpoint(
id,
method,
path,
description,
'Owner only',
[...errors, e(403, OWNER_REQUIRED_MESSAGE), e(429, TOO_MANY_REQUESTS)],
response,
{ rateLimited: true, pathId: path.includes('{id}') ? 7 : null, ...extra },
)
export const API_GROUPS: ApiGroup[] = [
{
key: 'articles',
title: 'Articles',
description: "Read, write and publish articles. Titles can't be changed after creation.",
endpoints: [
endpoint(
'art-list',
'GET',
'/v1/article/',
'List articles. Anonymous callers get online articles only.',
'Public',
articleErrors(),
json([article()]),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-get',
'GET',
'/v1/article/{id}',
'Get one article. Drafts answer 404 to anonymous callers.',
'Public',
articleErrors(...articleIdErrors),
json(article()),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-create',
'POST',
'/v1/article/',
'Create a draft article.',
'Verified user',
verified(
articleErrors(
e(400, BODY_MISMATCH),
e(400, 'Article title must be between 3 and 200 characters.'),
),
),
json(
article({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
}),
),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'title',
type: 'string',
required: true,
rule: "3 to 200 characters. Can't be changed later.",
},
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
},
],
bodySample: {
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
},
},
),
endpoint(
'art-update',
'PATCH',
'/v1/article/{id}',
'Update the body of an article.',
'Verified user',
verified(articleErrors(e(400, BODY_MISMATCH), ...articleIdErrors)),
json(article({ body: 'Updated text.', is_online: false })),
{
query: [FORMAT_QUERY],
bodyKind: 'json',
body: [
{
name: 'body',
type: 'string',
required: true,
rule: 'Up to 10 000 characters.',
},
],
bodySample: { body: 'Updated text.' },
},
),
endpoint(
'art-pub',
'PATCH',
'/v1/article/{id}/publish',
'Publish an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
json(article({ is_online: true })),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-unpub',
'PATCH',
'/v1/article/{id}/unpublish',
'Unpublish an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
json(article({ is_online: false })),
{ query: [FORMAT_QUERY] },
),
endpoint(
'art-del',
'DELETE',
'/v1/article/{id}',
'Delete an article.',
'Verified user',
verified(articleErrors(...articleIdErrors)),
NO_CONTENT_RESPONSE,
{ query: [FORMAT_QUERY] },
),
],
},
{
key: 'media',
title: 'Media',
description: 'The image library. One image can be used in many articles.',
endpoints: [
endpoint(
'med-list',
'GET',
'/v1/media/',
'List images.',
'Verified user',
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
json({
items: [mediaItem()],
next_cursor: null,
counts: { all: 1, unused: 0, duplicates: 0 },
}),
{ query: MEDIA_QUERY },
),
endpoint(
'med-up',
'POST',
'/v1/media/',
'Upload an image (multipart).',
'Verified user',
verified([
e(400, 'the image could not be read'),
e(401, UNAUTHORIZED),
e(413, 'the file exceeds the maximum upload size'),
e(415, 'the file must be a PNG, JPEG, WEBP, or SVG image'),
]),
json(mediaItem()),
{
pathId: null,
bodyKind: 'multipart',
body: [
{
name: 'file',
type: 'file',
required: true,
rule: 'PNG, JPEG, WEBP or SVG.',
},
],
bodySample: { file: '@north-wall.jpg' },
},
),
endpoint(
'med-ren',
'PATCH',
'/v1/media/{id}',
'Rename an image. The URL stays the same.',
'Verified user',
verified([
e(400, 'Enter a filename.'),
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Image not found'),
]),
json(mediaItem({ filename: 'north-wall-01.jpg' })),
{
pathId: 4,
bodyKind: 'json',
body: [
{
name: 'filename',
type: 'string',
required: true,
rule: 'The new filename.',
},
],
bodySample: { filename: 'north-wall-01.jpg' },
},
),
endpoint(
'med-del',
'DELETE',
'/v1/media/{id}',
'Delete an image. Articles that use it show a broken image.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
NO_CONTENT_RESPONSE,
{ pathId: 4 },
),
endpoint(
'med-merge',
'POST',
'/v1/media/merge',
'Merge identical copies into one image.',
'Verified user',
verified([
e(
400,
'Choose an image to keep and at least one other image to merge into it.',
),
e(401, UNAUTHORIZED),
e(409, 'Some of the images to merge changed. Refresh and try again.'),
]),
json({ articles_updated: 5, merged: 2 }),
{
bodyKind: 'json',
body: [
{
name: 'keep_id',
type: 'number',
required: true,
rule: 'Greater than 0. The image to keep.',
},
{
name: 'merge_ids',
type: 'number[]',
required: true,
rule: 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.',
},
],
bodySample: { keep_id: 4, merge_ids: [9, 11] },
},
),
endpoint(
'med-use',
'GET',
'/v1/media/{id}/usage',
'List the articles that use an image.',
'Verified user',
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
json([{ id: 12, title: ARTICLE_TITLE, published: true }]),
{ pathId: 4 },
),
],
},
{
key: 'users',
title: 'Users and invites',
description: 'People who can sign in, and pending invitations.',
endpoints: [
endpoint(
'usr-list',
'GET',
'/v1/user/',
'List users and invites.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json([
{
id: 1,
kind: 'user',
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-01-05T08:30:00Z',
},
{
id: 7,
kind: 'invite',
email: 'lea.martin@renew.example',
status: 'pending',
created_at: '2026-09-10T07:00:00Z',
},
]),
),
endpoint(
'usr-me',
'PATCH',
'/v1/user/me',
'Update my profile.',
'Verified user',
verified([
e(400, BODY_MISMATCH),
e(400, 'Display name must be 60 characters or fewer.'),
e(401, UNAUTHORIZED),
]),
json({ ...me, display_name: 'Marta Alves' }),
{
bodyKind: 'json',
body: [
{
name: 'display_name',
type: 'string',
required: true,
rule: 'Up to 60 characters.',
},
],
bodySample: { display_name: 'Marta Alves' },
},
),
ownerOnly(
'usr-inv',
'POST',
'/v1/user/invite',
'Invite a user by email.',
[
e(400, BODY_MISMATCH),
e(401, UNAUTHORIZED),
e(409, 'This address already has an account.'),
e(409, "There's already a pending invite for this address."),
],
json(invite()),
{
bodyKind: 'json',
body: [
{
name: 'email',
type: 'string',
required: true,
rule: 'A valid email address.',
},
],
bodySample: { email: 'lea.martin@renew.example' },
},
),
ownerOnly(
'usr-cancel',
'DELETE',
'/v1/user/invite/{id}',
'Cancel a pending invite.',
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Invite not found')],
NO_CONTENT_RESPONSE,
),
ownerOnly(
'usr-renew',
'POST',
'/v1/user/invite/{id}/renew',
'Renew an expired invite.',
[
e(400, ID_FORMAT),
e(401, UNAUTHORIZED),
e(404, 'Invite not found'),
e(409, 'This invite has already been accepted.'),
],
json(invite()),
),
endpoint(
'auth-me',
'GET',
'/v1/auth/me',
"Get the current user. With a key, the key's owner.",
'Verified user',
[e(401, UNAUTHORIZED)],
json(me),
),
],
},
{
key: 'settings',
title: 'Settings',
description: 'Read the site settings.',
endpoints: [
endpoint(
'set-get',
'GET',
'/v1/settings',
'Get settings.',
'Verified user',
verified([e(401, UNAUTHORIZED)]),
json({ version: 'v2.2.0-beta' }),
),
],
},
]
const row = (
method: HttpMethod,
path: string,
description: string,
answers403 = true,
): SessionRow => ({
method,
path,
description: answers403 ? description : `${description}. ${LOGIN_FAMILY_CAPTION}`,
answers403,
})
export const SESSION_GROUPS: SessionGroup[] = [
{
title: 'System · owner only',
rows: [
row('GET', '/v1/system/update', 'Update status'),
row('POST', '/v1/system/update/check', 'Check for update'),
row('POST', '/v1/system/update/download', 'Download update'),
row('POST', '/v1/system/update/restart', 'Restart'),
],
},
{
title: 'Sign in and account',
rows: [
row('POST', '/v1/auth/login', 'Log in', false),
row('POST', '/v1/auth/logout', 'Log out', false),
row('POST', '/v1/auth/register', 'Register', false),
row('POST', '/v1/auth/verify', 'Verify email'),
row('DELETE', '/v1/auth/register', 'Cancel registration'),
row('POST', '/v1/auth/password-reset', 'Reset password', false),
],
},
{
title: 'API key management',
rows: [
row('GET', '/v1/api-key', 'List keys'),
row('POST', '/v1/api-key', 'Create a key'),
row('DELETE', '/v1/api-key/{id}', 'Revoke a key'),
],
},
]
export const PUBLIC_AUTH_ROWS: PublicRow[] = [
{ method: 'GET', path: '/v1/', description: 'Hello world.' },
{ method: 'GET', path: '/v1/auth/registration', description: 'Whether registration is open.' },
{
method: 'POST',
path: '/v1/auth/password-reset/confirm',
description: 'Set a new password with the emailed code.',
},
{ method: 'GET', path: '/v1/auth/invite', description: 'Check an invite code.' },
{ method: 'POST', path: '/v1/auth/invite/accept', description: 'Accept an invite.' },
{ method: 'GET', path: '/healthz', description: 'Plain text "ok". Outside /v1.' },
]
export const ERROR_ROWS: ErrorRow[] = [
{
status: 400,
meaning: 'Bad request',
messages: [BODY_MISMATCH, 'The expiry must be a date in the future.'],
},
{ status: 401, meaning: 'Unauthorized', messages: [UNAUTHORIZED] },
{
status: 403,
meaning: 'Forbidden',
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
},
{
status: 404,
meaning: 'Not found',
messages: ['Article not found', 'Image not found', 'Invite not found', 'API key not found'],
},
{
status: 409,
meaning: 'Conflict',
messages: [
"There's already a pending invite for this address.",
'This invite has already been accepted.',
],
},
{ status: 429, meaning: 'Too many requests', messages: [TOO_MANY_REQUESTS] },
{ status: 500, meaning: 'Server error', messages: ['Internal server error'] },
]
export const ERROR_SAMPLE = `HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\n${SESSION_REQUIRED_MESSAGE}`
export const AUTH_EXAMPLE_PATH = '/v1/auth/me'
function resolvedPath(item: Pick<ApiEndpoint, 'path' | 'pathId'>) {
return item.pathId === null ? item.path : item.path.replace('{id}', String(item.pathId))
}
export function endpointExamples(item: ApiEndpoint, origin: string): EndpointExamples {
const url = `${origin}${resolvedPath(item)}`
const isPublic = item.access === 'Public'
const isJson = item.bodyKind === 'json' && item.bodySample !== null
const isForm = item.bodyKind === 'multipart'
const authHeader = `Authorization: Bearer ${API_KEY_PLACEHOLDER}`
const curlParts = [`curl ${item.method === 'GET' ? '' : `-X ${item.method} `}"${url}"`]
if (!isPublic) curlParts.push(` -H "${authHeader}"`)
if (isJson) {
curlParts.push(` -H "Content-Type: application/json"`)
curlParts.push(` -d '${JSON.stringify(item.bodySample)}'`)
}
if (isForm) curlParts.push(` -F "file=@north-wall.jpg"`)
const options: string[] = []
if (item.method !== 'GET') options.push(`method: '${item.method}'`)
const headers = [
isPublic ? null : `Authorization: 'Bearer ${API_KEY_PLACEHOLDER}'`,
isJson ? `'Content-Type': 'application/json'` : null,
].filter((header) => header !== null)
if (headers.length > 0) options.push(`headers: { ${headers.join(', ')} }`)
if (isJson) options.push(`body: JSON.stringify(${JSON.stringify(item.bodySample)})`)
if (isForm) options.push('body: form')
const formSetup = isForm ? "const form = new FormData()\nform.append('file', file)\n\n" : ''
const call = `const res = await fetch('${url}'${
options.length > 0 ? `, {\n ${options.join(',\n ')}\n})` : ')'
}`
return {
fetch: formSetup + call,
curl: curlParts.join(' \\\n'),
response: item.response,
}
}
export function authExamples(origin: string) {
const url = `${origin}${AUTH_EXAMPLE_PATH}`
return {
curl: `curl "${url}" \\\n -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}"`,
fetch: `const res = await fetch('${url}', {\n headers: { Authorization: 'Bearer ${API_KEY_PLACEHOLDER}' }\n})\nconst me = await res.json()`,
}
}
export function allEndpoints() {
return API_GROUPS.flatMap((group) => group.endpoints)
}
+66
View File
@@ -884,6 +884,72 @@ export const messages = {
'That key no longer exists.': 'Cette clé n’existe plus.',
'Manage API keys': 'Gérer les clés d’API',
'Loading API keys': 'Chargement des clés d’API',
Required: 'Obligatoire',
Rule: 'Règle',
Yes: 'Oui',
No: 'Non',
'Request body · multipart/form-data, 1 file field':
'Corps de la requête · multipart/form-data, 1 champ fichier',
'Request body · JSON': 'Corps de la requête · JSON',
'Request body': 'Corps de la requête',
'API key OK': 'Clé d’API acceptée',
'Query parameters': 'Paramètres d’URL',
'No query parameters.': 'Aucun paramètre d’URL.',
'No request body.': 'Aucun corps de requête.',
Errors: 'Erreurs',
'On this page': 'Sur cette page',
'Jump to section': 'Aller à la section',
'Jump to': 'Aller à',
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."':
'Ces routes sont prévues pour une session de navigateur connectée. Avec une clé d’API, verify, DELETE /auth/register, /api-key/* et /system/* répondent 403 « This action needs a real session. »',
'Session only': 'Session uniquement',
'Public auth flow': 'Parcours d’authentification public',
Public: 'Public',
'Verified user': 'Utilisateur vérifié',
'Owner only': 'Propriétaire uniquement',
Authentication: 'Authentification',
Notes: 'Notes',
'Users and invites': 'Utilisateurs et invitations',
'System · owner only': 'Système · propriétaire uniquement',
'Sign in and account': 'Connexion et compte',
'API key management': 'Gestion des clés d’API',
'Bad request': 'Requête invalide',
Unauthorized: 'Non autorisé',
Forbidden: 'Interdit',
Conflict: 'Conflit',
'Server error': 'Erreur serveur',
'This route is rate limited. Too many requests answer 429.':
'Cette route est limitée en fréquence. Trop de requêtes reçoivent la réponse 429.',
Message: 'Message',
'{0} route': '{0} route',
'{0} routes': '{0} routes',
'{0} routes, made for a browser session': '{0} routes, prévues pour une session de navigateur',
'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.',
'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.',
'API key': 'Clé d’API',
'Full access': 'Accès complet',
"Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.":
'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é.',
'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.':
'Chaque erreur est un corps en texte brut d’une seule phrase. Le code d’état est dans la ligne d’état HTTP, et le Content-Type est text/plain.',
'Error response · text/plain': 'Réponse d’erreur · text/plain',
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.':
'Les images dans `body` utilisent des chemins comme `/static/uploadedImages/<uuid>.png`. Ajoutez l’URL de base devant avant l’affichage.',
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.':
'Les routes d’articles acceptent `?format=markdown`, `?format=html` ou `?format=json` pour choisir le format de `body`. Avec `json`, `body` est la structure JSON stockée. Les articles ont `body_format: markdown` par défaut.',
Meaning: 'Signification',
} as const
export type MessageKey = keyof typeof messages
+4 -1
View File
@@ -391,9 +391,12 @@ describe('Hardener localization outcomes', () => {
document.body.textContent?.includes('/v1/article/'),
document.body.textContent?.includes('GET'),
]).toEqual([window.location.origin, true, true, true])
document.body
.querySelector<HTMLButtonElement>('button[aria-label^="GET /v1/article/. "]')!
.click()
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/?format=html')`,
`const res = await fetch('${window.location.origin}/v1/article/')`,
),
)
vi.unstubAllGlobals()
+28
View File
@@ -0,0 +1,28 @@
import type { HttpMethod } from '@/data/apiCatalogue'
const CHIP_BASE = 'h-5 gap-1 rounded-none px-1.5 py-0 text-xs whitespace-nowrap ring-0 border'
export const CHIP_CLASS = {
neutral: `${CHIP_BASE} border-accented bg-transparent font-normal text-toned`,
strong: `${CHIP_BASE} border-accented bg-elevated font-medium text-highlighted`,
success: `${CHIP_BASE} border-transparent bg-(--rc-success-soft) font-medium text-success`,
dashed: `${CHIP_BASE} border-dashed border-accented bg-transparent font-normal text-muted`,
}
const METHOD_BASE =
'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border'
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`,
DELETE: `${METHOD_BASE} border-error bg-(--rc-error-soft) text-error`,
}
export function splitInlineCode(text: string) {
return text.split('`').map((value, index) => ({ value, code: index % 2 === 1 }))
}
export function endpointAnchor(id: string) {
return `ep-${id}`
}
+383 -117
View File
@@ -1,164 +1,430 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { t, translate } from '@/i18n'
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import {
API_GROUPS,
ERROR_ROWS,
ERROR_SAMPLE,
PUBLIC_AUTH_ROWS,
SESSION_GROUPS,
authExamples,
} from '@/data/apiCatalogue'
const SPY_OFFSET_PX = 120
const origin = computed(() => window.location.origin)
const { copied, copy } = useCopyToClipboard()
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyButtonIcon = computed(() =>
copied.value ? 'i-lucide-check' : 'i-lucide-copy',
)
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
function copyBaseUrl() {
copy(origin.value)
}
const sampleArticle = {
id: 12,
title: 'How we price a bathroom renovation',
body: '<p>Every bathroom is different, but the way we price them isn’t.</p>',
images: [
const root = ref<HTMLElement | null>(null)
const activeSection = ref('auth')
const authTab = ref('curl')
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
])
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
{ label: 'curl', value: 'curl', code: examples.curl, lang: 'shell', title: 'Shell' },
{
id: 1,
path: '/static/uploadedImages/2f6a1c0e-2b8b-4c3d-9c1e-1a2b3c4d5e6f.png',
article_id: 12,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-03-12T09:14:00Z',
label: 'fetch',
value: 'fetch',
code: examples.fetch,
lang: 'javascript',
title: 'JavaScript',
},
],
body_format: 'html',
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-10T07:00:00Z',
}
]
})
const sampleArticleList = [sampleArticle]
const endpoints = computed(() => [
{
method: 'GET' as const,
path: '/v1/article/',
get description() {
return t('Lists every online article.')
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`,
curl: `curl ${origin.value}/v1/article/?format=html`,
response: JSON.stringify(sampleArticleList, null, 2),
},
},
{
method: 'GET' as const,
path: '/v1/article/{id}',
get description() {
return t(
'Returns one online article. Drafts and unknown ids both return 404.',
)
},
examples: {
fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`,
curl: `curl ${origin.value}/v1/article/12?format=html`,
response: JSON.stringify(sampleArticle, null, 2),
},
},
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.',
),
t(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
),
])
const notes = computed(() => [
t(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
),
t(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.',
),
t(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
),
t(
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.',
),
])
const errorColumns = computed(() => [
{
accessorKey: 'status',
get header() {
return t('Status')
},
meta: { class: { th: 'w-17 lg:w-19', td: 'w-17 lg:w-19' } },
},
{
accessorKey: 'meaning',
get header() {
return t('Meaning')
},
meta: { class: { th: 'lg:w-53', td: 'lg:w-53' } },
},
{
accessorKey: 'messages',
get header() {
return t('Message')
},
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
},
])
function quoteMessages(messages: string[]) {
return messages.map((message) => `"${message}"`).join(' / ')
}
function routeCount(count: number) {
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
}
function scrollParent(element: HTMLElement) {
let current = element.parentElement
while (current) {
const overflow = getComputedStyle(current).overflowY
if (overflow === 'auto' || overflow === 'scroll') {
return current
}
current = current.parentElement
}
return null
}
function sectionElement(id: string) {
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
}
function goTo(id: string) {
activeSection.value = id
sectionElement(id)?.scrollIntoView?.({ block: 'start' })
}
function updateActive() {
const element = root.value
const parent = element ? scrollParent(element) : null
if (!parent) {
return
}
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
let current = sections.value[0]!.id
for (const section of sections.value) {
const target = sectionElement(section.id)
if (target && target.getBoundingClientRect().top <= limit) {
current = section.id
}
}
activeSection.value = current
}
let scroller: HTMLElement | null = null
onMounted(() => {
scroller = root.value ? scrollParent(root.value) : null
scroller?.addEventListener('scroll', updateActive, { passive: true })
})
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
const SECTION_TITLE = 'text-lg font-semibold text-highlighted'
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</script>
<template>
<DashboardShell :mobile-title="t('Integration')">
<div class="flex max-w-199 flex-col gap-6">
<div class="flex flex-col gap-1">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('Connect your website') }}
</h1>
<p class="text-muted">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex flex-col gap-1.5">
<UBreadcrumb
:items="[
{
get label() {
return t('Integration')
},
},
]"
color="neutral"
:ui="{
link: 'text-sm font-normal text-muted',
linkLabel: 'text-muted',
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted lg:text-2xl">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
{{ t('Everything a developer needs to build on RenewCMS.') }}
</p>
</div>
<p class="max-w-180 text-base text-pretty text-toned">
{{
t(
'A guide for the developer who builds your public site.',
'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.',
)
}}
</p>
</div>
<p class="text-base text-pretty text-toned">
{{
t(
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.",
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{
t('Base URL')
}}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
</div>
</div>
</div>
<EndpointCard
v-for="endpoint in endpoints"
:key="endpoint.path"
:method="endpoint.method"
:path="endpoint.path"
:description="endpoint.description"
:examples="endpoint.examples"
/>
<section
data-anchor="auth"
aria-labelledby="h-auth"
:class="[SECTION_CLASS, 'gap-3.5']"
>
<h2 id="h-auth" :class="SECTION_TITLE">{{ t('Authentication') }}</h2>
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<div :class="AUTH_CARD">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('Public') }}</span>
<UBadge
:label="t('No key')"
color="neutral"
variant="outline"
class="h-5 rounded-none border border-accented px-1.5 py-0 text-xs font-normal text-toned ring-0"
/>
</div>
<span class="text-pretty text-toned">{{
t(
'No key needed. Two read-only routes, and only online articles are returned.',
)
}}</span>
<div
class="flex flex-col gap-1 font-mono text-xs font-medium text-highlighted"
>
<span>GET /v1/article/</span>
<span>GET /v1/article/{id}</span>
</div>
</div>
<div class="flex flex-col gap-2.5">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon
name="i-lucide-info"
class="mt-0.75 size-4 shrink-0 text-info"
/>
<span class="text-base text-pretty text-toned">{{
note
<div :class="[AUTH_CARD, 'min-w-0']">
<div class="flex items-center gap-2">
<span :class="CARD_TITLE">{{ t('API key') }}</span>
<UBadge
:label="t('Full access')"
color="success"
variant="soft"
class="h-5 rounded-none bg-(--rc-success-soft) px-1.5 py-0 text-xs font-medium text-success"
/>
</div>
<span class="text-pretty text-toned"
><CodeText
:text="
t(
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role (owner or admin). A key never has more rights than its creator.',
)
"
/></span>
<UTabs
v-model="authTab"
:items="authItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
:label="(item as (typeof authItems)[number]).title"
:lang="(item as (typeof authItems)[number]).lang"
:code="(item as (typeof authItems)[number]).code"
/>
</template>
</UTabs>
<span class="text-pretty text-muted"
><CodeText
:text="
t(
'Keys start with `rcms_` and are shown in full once, when you create them.',
)
"
/></span>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="min-h-11 self-start px-0 lg:min-h-7"
>
{{ t('Manage API keys') }}
</UButton>
</div>
</div>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in authNotes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</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">{{ group.description }}</span>
<div class="border border-t-0 border-default bg-default">
<EndpointCard
v-for="endpoint in group.endpoints"
:key="endpoint.id"
:endpoint="endpoint"
:origin="origin"
/>
</div>
</section>
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
<span class="text-muted">{{
t('{0} routes, made for a browser session', [sessionRouteCount])
}}</span>
</div>
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
</section>
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
<h2 id="h-errors" :class="SECTION_TITLE">{{ t('Errors') }}</h2>
<span class="text-pretty text-toned">{{
t(
'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.',
)
}}</span>
</div>
<UTable
:data="ERROR_ROWS"
:columns="errorColumns"
:ui="{
root: 'border border-default bg-default',
base: 'table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'px-3 py-2 text-sm font-medium text-muted',
td: 'px-3 py-2.5 align-top text-sm whitespace-normal text-toned',
tbody: 'divide-y-0',
tr: 'border-t border-default',
}"
>
<template #status-cell="{ row }">
<span class="font-mono text-sm font-medium text-highlighted">{{
row.original.status
}}</span>
</template>
<template #meaning-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="font-medium 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>
</div>
</div>
</template>
<template #messages-cell="{ row }">
<div class="flex flex-col gap-0.5">
<span class="text-pretty">{{
quoteMessages(row.original.messages)
}}</span>
</div>
</template>
</UTable>
<div class="min-w-0 border border-default bg-elevated">
<div
class="flex h-8 items-center border-b border-default px-3 font-mono text-xs font-medium text-muted"
>
{{ t('Error response · text/plain') }}
</div>
<pre
tabindex="0"
class="overflow-x-auto p-3 font-mono text-code whitespace-pre text-default"
>{{ ERROR_SAMPLE }}</pre
>
</div>
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
<h2 id="h-notes" :class="SECTION_TITLE">{{ t('Notes') }}</h2>
<div class="flex flex-col gap-2.5 text-base text-toned">
<div v-for="note in notes" :key="note" class="flex gap-2.5">
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
<span class="text-pretty"><CodeText :text="note" /></span>
</div>
</div>
</section>
</div>
<UButton
to="/integration/api-keys"
variant="link"
trailing-icon="i-lucide-arrow-right"
class="self-start px-0"
>
{{ t('Manage API keys') }}
</UButton>
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
</div>
</DashboardShell>
</template>
+488 -114
View File
@@ -1,16 +1,34 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import UButton from '@nuxt/ui/components/Button.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import {
API_KEY_PLACEHOLDER,
ERROR_SAMPLE,
authExamples,
allEndpoints,
endpointExamples,
} from '@/data/apiCatalogue'
import IntegrationView from '../IntegrationView.vue'
const STUB_ORIGIN = 'https://cms.renew.example'
const SECTION_LABELS = [
'Authentication',
'Articles',
'Media',
'Users and invites',
'Settings',
'Session only',
'Errors',
'Notes',
]
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
function stubOrigin(origin: string) {
vi.stubGlobal('location', { ...window.location, origin })
@@ -26,6 +44,9 @@ function stubClipboard() {
}
async function mountIntegration() {
stubOrigin(STUB_ORIGIN)
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
@@ -42,10 +63,56 @@ async function mountIntegration() {
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, name: string) {
function findButton(wrapper: VueWrapper, name: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === name)
}
function row(wrapper: VueWrapper, route: string) {
const found = wrapper
.findAll('button[aria-expanded]')
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))
if (!found) {
throw new Error(`no row for ${route}`)
}
return found
}
function card(wrapper: VueWrapper, route: string) {
const [method, path] = route.split(' ')
return wrapper
.findAllComponents(EndpointCard)
.find(
(item) =>
item.props('endpoint').method === method && item.props('endpoint').path === path,
)!
}
async function expand(wrapper: VueWrapper, route: string) {
await row(wrapper, route).trigger('click')
await flushPromises()
return card(wrapper, route)
}
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
function rowRoute(item: ReturnType<VueWrapper['get']>) {
const [method, path] = item.findAll('span.font-mono').map((span) => span.text())
return `${method} ${path}`
}
function section(wrapper: VueWrapper, id: string) {
return wrapper.get(`[data-anchor="${id}"]`)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
@@ -54,89 +121,154 @@ afterEach(async () => {
await router.push('/login')
})
describe("Default — the page loads with the app's own origin", () => {
it('renders the header, base URL, endpoint cards and notes with the stubbed origin', async () => {
stubOrigin(STUB_ORIGIN)
describe('Default, every card collapsed', () => {
it('renders the header, intro, base URL and collapsed cards', async () => {
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Connect your website')
expect(wrapper.text()).toContain('A guide for the developer who builds your public site.')
expect(wrapper.text()).toContain('Integration')
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 and publishes your articles but doesn't show them to visitors.",
'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.',
)
expect(wrapper.text()).toContain(STUB_ORIGIN)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const cardTexts = wrapper.findAllComponents(EndpointCard).map((c) => c.text())
expect(cardTexts).toHaveLength(2)
expect(cardTexts[0]).toContain('/v1/article/')
expect(cardTexts[0]).toContain('Lists every online article.')
expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`)
expect(cardTexts[1]).toContain('/v1/article/{id}')
expect(cardTexts[1]).toContain(
'Returns one online article. Drafts and unknown ids both return 404.',
)
expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`)
const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch')
expect(fetchTabs.length).toBeGreaterThan(0)
fetchTabs.forEach((tab) => {
expect(tab.attributes('aria-selected')).toBe('true')
})
expect(wrapper.text()).toContain(
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.',
)
expect(wrapper.text()).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(wrapper.text()).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
})
it('renders the breadcrumb Integration', async () => {
stubOrigin(STUB_ORIGIN)
const wrapper = await mountIntegration()
expect(wrapper.text()).toContain('Integration')
})
})
describe('Manage API keys link', () => {
it('sits below the three notes as a link-variant button with a trailing arrow and navigates to the keys screen', async () => {
stubOrigin(STUB_ORIGIN)
const wrapper = await mountIntegration()
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(anchor.attributes('href')).toBe('/integration/api-keys')
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
const lastNote = wrapper
.findAll('span')
.find((span) => span.text().startsWith('Browsers can only call the API'))!
const rows = wrapper
.findAllComponents(EndpointCard)
.map((item) => item.get('button[aria-expanded]'))
expect(rows).toHaveLength(allEndpoints().length)
rows.forEach((button) => expect(button.attributes('aria-expanded')).toBe('false'))
expect(
lastNote.element.compareDocumentPosition(anchor.element) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
wrapper.findAllComponents(EndpointCard).flatMap((item) => item.findAll('[role="tab"]')),
).toHaveLength(0)
})
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
it('lists the sections in the sticky index and the Jump to select', async () => {
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
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')
})
it('shows Owner only on the three invite rows and Verified user elsewhere', async () => {
const wrapper = await mountIntegration()
for (const route of [
'POST /v1/user/invite',
'DELETE /v1/user/invite/{id}',
'POST /v1/user/invite/{id}/renew',
]) {
expect(row(wrapper, route).text()).toContain('Owner only')
expect(row(wrapper, route).text()).not.toContain('Verified user')
}
expect(row(wrapper, 'GET /v1/auth/me').text()).toContain('Verified user')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('Public')
expect(row(wrapper, 'GET /v1/article/').text()).toContain('API key OK')
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the 20 cards as 7, 6, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([7, 6, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('7 routes')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
it('makes no /v1 request while rendering or expanding cards', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'POST /v1/article/')
await expand(wrapper, 'GET /v1/media/')
expect(globalThis.fetch).toBe(fetchSpy)
expect(fetchSpy).not.toHaveBeenCalled()
})
})
describe('Copied — the Base URL copy button', () => {
describe('Group scrolled, one card expanded', () => {
it('reports aria-expanded and shows the tables, tabs, code and errors', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
expect(row(wrapper, 'POST /v1/article/').attributes('aria-expanded')).toBe('true')
const text = created.text()
expect(text.indexOf('Query parameters')).toBeGreaterThan(-1)
expect(text.indexOf('Query parameters')).toBeLessThan(text.indexOf('Request body'))
expect(text).toContain('format')
expect(text).toContain('markdown, html or json. Default markdown.')
expect(text).toContain('title')
expect(text).toContain("3 to 200 characters. Can't be changed later.")
expect(text).toContain('Up to 10 000 characters.')
const tabs = created.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(text).toContain('JavaScript')
expect(
created
.findAll('button')
.some((button) => button.attributes('aria-label') === 'Copy JavaScript example'),
).toBe(true)
expect(text).toContain('Errors')
const statuses = created
.findAll('[title]')
.map((item) => item.text())
.join(' ')
expect(statuses).toContain('400')
expect(statuses).toContain('401')
expect(text).toContain('Article title must be between 3 and 200 characters.')
expect(text).toContain('The format must be one of: markdown, html, json.')
})
it('shows "No query parameters." and "No request body." when a card has none', async () => {
const wrapper = await mountIntegration()
const published = await expand(wrapper, 'GET /v1/settings')
expect(published.text()).toContain('No query parameters.')
expect(published.text()).toContain('No request body.')
})
it('shows the six media query parameters on the media list', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
expect(list.text()).toContain(name)
}
expect(list.text()).toContain('1 to 100. Default 24.')
})
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
const wrapper = await mountIntegration()
const upload = await expand(wrapper, 'POST /v1/media/')
expect(upload.text()).toContain('Request body · multipart/form-data, 1 file field')
expect(upload.text()).toContain('file')
expect(upload.text()).toContain('the image could not be read')
expect(upload.text()).toContain('the file exceeds the maximum upload size')
expect(upload.text()).toContain('the file must be a PNG, JPEG, WEBP, or SVG image')
const invite = await expand(wrapper, 'POST /v1/user/invite')
expect(invite.text()).toContain('Only the owner can do this.')
expect(invite.text()).toContain('This route is rate limited. Too many requests answer 429.')
expect(invite.text()).toContain("There's already a pending invite for this address.")
})
})
describe('Copied, the Base URL copy button', () => {
it('writes the origin to the clipboard, shows Copied for 2s, then reverts', async () => {
stubOrigin(STUB_ORIGIN)
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const copyButton = findButton(wrapper, 'Copy')!
await copyButton.trigger('click')
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
@@ -150,66 +282,308 @@ describe('Copied — the Base URL copy button', () => {
})
})
describe("Each EndpointCard's own Copy button is independent of the Base URL's", () => {
it("copies only that card's code and leaves the other buttons unaffected", async () => {
stubOrigin(STUB_ORIGIN)
describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", () => {
it("copies only that card's exact code and leaves the other buttons unaffected", async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
const cards = wrapper.findAllComponents(EndpointCard)
const firstCardCopyButton = cards[0]
const button = created
.findAll('button')
.find((button) => button.text().trim() === 'Copy')!
await firstCardCopyButton.trigger('click')
.find((item) => item.attributes('aria-label') === 'Copy JavaScript example')!
await button.trigger('click')
await flushPromises()
const expectedCode =
`const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` +
'const articles = await res.json()'
expect(writeText).toHaveBeenCalledWith(expectedCode)
const expected = endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch
expect(writeText).toHaveBeenCalledWith(expected)
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
expect(copied).toHaveLength(1)
expect(copied[0]!.attributes('aria-label')).toBe('Copy JavaScript example')
expect(findButton(wrapper, 'Copy')).toBeTruthy()
const copyButtons = wrapper.findAll('button').filter((button) => {
const text = button.text().trim()
return text === 'Copy' || text === 'Copied'
})
const copiedButtons = copyButtons.filter((button) => button.text().trim() === 'Copied')
expect(copiedButtons).toHaveLength(1)
const baseUrlCopyButton = findButton(wrapper, 'Copy')
expect(baseUrlCopyButton).toBeTruthy()
const secondCardCopyButton = cards[1]
.findAll('button')
.find((button) => button.text().trim() === 'Copy')
expect(secondCardCopyButton).toBeTruthy()
await vi.advanceTimersByTimeAsync(2000)
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
describe('Authentication', () => {
it('renders the Public and API key cards, the tabs for GET /v1/auth/me, the link and both notes', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
describe('curl and Response tabs render, unchecked visually', () => {
it('shows the curl command and the Response JSON sample built from the real Article fields', async () => {
stubOrigin(STUB_ORIGIN)
expect(auth.text()).toContain(
'No key needed. Two read-only routes, and only online articles are returned.',
)
expect(auth.text()).toContain('GET /v1/article/')
expect(auth.text()).toContain('GET /v1/article/{id}')
expect(auth.text()).toContain(
"Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.",
)
expect(auth.text()).toContain(
'Keys start with rcms_ and are shown in full once, when you create them.',
)
const tabs = auth.findAll('[role="tab"]').map((tab) => tab.text())
expect(tabs).toEqual(['curl', 'fetch'])
const examples = authExamples(STUB_ORIGIN)
expect(auth.text()).toContain(`${STUB_ORIGIN}/v1/auth/me`)
expect(auth.text()).toContain(API_KEY_PLACEHOLDER)
await activateTab(auth.element as HTMLElement, 'fetch')
expect(auth.text()).toContain(`fetch('${STUB_ORIGIN}/v1/auth/me'`)
expect(examples.curl).toContain('Authorization: Bearer rcms_')
const link = auth.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
expect(link.attributes('href')).toBe('/integration/api-keys')
expect(auth.text()).toContain(
'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.',
)
expect(auth.text()).toContain(
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a Retry-After header.',
)
})
it('navigates the Manage API keys link to the keys screen', async () => {
const wrapper = await mountIntegration()
const firstCard = wrapper.findAllComponents(EndpointCard)[0]
await activateTab(firstCard.element as HTMLElement, 'curl')
const anchor = wrapper.findAll('a').find((a) => a.text().trim() === 'Manage API keys')!
const button = wrapper
.findAllComponents(UButton)
.find((component) => component.element === anchor.element)!
expect(button.props('variant')).toBe('link')
expect(button.props('trailingIcon')).toBe('i-lucide-arrow-right')
expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`)
await anchor.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/integration/api-keys'))
})
})
await activateTab(firstCard.element as HTMLElement, 'Response')
describe('Session only', () => {
it('renders the banner, 13 rows without expand controls and the 403 chips', async () => {
const wrapper = await mountIntegration()
const session = section(wrapper, 'session')
expect(firstCard.text()).toContain('"is_online"')
expect(firstCard.text()).toContain('"created_at"')
expect(firstCard.text()).toContain('"id"')
expect(session.text()).toContain('13 routes')
expect(session.text()).toContain(
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session."',
)
const rows = session.findAll('[data-session-row]')
expect(rows).toHaveLength(13)
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
const chipRows = rows.filter((item) => item.text().includes('Session only'))
expect(chipRows.map(rowRoute)).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',
'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),
)
})
it('captions Log in, Log out, Register and Reset password without a 403 claim', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-session-row]')
for (const path of [
'/v1/auth/login',
'/v1/auth/logout',
'/v1/auth/register',
'/v1/auth/password-reset',
]) {
const item = rows.find(
(candidate) =>
candidate.text().includes(path) &&
candidate.text().includes('Made for the browser.'),
)!
expect(item.text()).toContain('A key is ignored here, with no 403.')
expect(item.text()).not.toContain('Session only')
}
})
it('lists the three sub-groups and keeps /v1/system out of the expandable groups', 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 rowLabels = wrapper
.findAll('button[aria-expanded]')
.map((button) => button.attributes('aria-label'))
expect(rowLabels.some((label) => label?.includes('/v1/system'))).toBe(false)
})
it('lists the 6 Public auth flow rows with the Public chip and no lock', async () => {
const wrapper = await mountIntegration()
const rows = section(wrapper, 'session').findAll('[data-public-row]')
expect(rows).toHaveLength(6)
expect(rows.map((item) => item.text())).toEqual([
expect.stringContaining('GET/v1/Hello world.'),
expect.stringContaining('GET/v1/auth/registrationWhether registration is open.'),
expect.stringContaining(
'POST/v1/auth/password-reset/confirmSet a new password with the emailed code.',
),
expect.stringContaining('GET/v1/auth/inviteCheck an invite code.'),
expect.stringContaining('POST/v1/auth/invite/acceptAccept an invite.'),
expect.stringContaining('GET/healthzPlain text "ok". Outside /v1.'),
])
rows.forEach((item) => {
expect(item.text()).toContain('Public')
expect(item.text()).not.toContain('Session only')
expect(item.find('[class*="lucide-lock"]').exists()).toBe(false)
})
})
})
describe('Errors', () => {
it('renders the intro, the real messages and the 403 sample', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.text()).toContain(
'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.',
)
const rows = errors.findAll('tbody tr')
expect(rows.map((item) => item.find('td').text())).toEqual([
'400',
'401',
'403',
'404',
'409',
'429',
'500',
])
const text = errors.text()
for (const message of [
'The request cannot be processed due to a mismatch in the format of the body.',
'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 the owner can do this.',
'You must verify your account to access this resource.',
'Article not found',
'Image not found',
'Invite not found',
'API key not found',
"There's already a pending invite for this address.",
'This invite has already been accepted.',
'Too many requests. Please try again later.',
'Internal server error',
]) {
expect(text).toContain(message)
}
const sample = errors.get('pre').text()
expect(sample).toBe(ERROR_SAMPLE)
expect(sample.split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
})
describe('Navigation', () => {
it('scrolls to the anchor and marks the index item current', async () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
})
const wrapper = await mountIntegration()
const index = wrapper.get('nav[aria-label="On this page"]')
const media = index.findAll('button').find((button) => button.text() === 'Media')!
expect(media.attributes('aria-current')).toBeUndefined()
await media.trigger('click')
await flushPromises()
expect(media.attributes('aria-current')).toBe('true')
expect(scrollIntoView).toHaveBeenCalledTimes(1)
expect(scrollIntoView.mock.contexts[0]).toBe(section(wrapper, 'media').element)
const current = index.findAll('button[aria-current="true"]')
expect(current.map((button) => button.text())).toEqual(['Media'])
})
it('lists the same sections in the mobile Jump to select', async () => {
const wrapper = await mountIntegration()
const jump = wrapper.get('button[aria-label="Jump to section"]')
await jump.trigger('click')
await flushPromises()
const options = Array.from(document.body.querySelectorAll('[role="option"]')).map(
(option) => option.textContent?.trim(),
)
expect(options).toEqual(SECTION_LABELS)
})
})
describe('Tabs, curl and Response render, unchecked visually', () => {
it('shows curl with the origin and the Authorization header on a non-public route', async () => {
const wrapper = await mountIntegration()
const created = await expand(wrapper, 'POST /v1/article/')
await activateTab(created.element as HTMLElement, 'curl')
expect(created.text()).toContain(`curl -X POST "${STUB_ORIGIN}/v1/article/"`)
expect(created.text()).toContain(`Authorization: Bearer ${API_KEY_PLACEHOLDER}`)
})
it('shows curl without an Authorization header on a public route', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/article/')
await activateTab(list.element as HTMLElement, 'curl')
expect(list.text()).toContain(`curl "${STUB_ORIGIN}/v1/article/"`)
expect(list.text()).not.toContain('Authorization')
})
it('shows the Response sample built from the real article fields', async () => {
const wrapper = await mountIntegration()
const single = await expand(wrapper, 'GET /v1/article/{id}')
await activateTab(single.element as HTMLElement, 'Response')
for (const key of [
'"id"',
'"title"',
'"body"',
'"body_format"',
'"is_online"',
'"created_at"',
'"updated_at"',
]) {
expect(single.text()).toContain(key)
}
expect(single.text()).not.toContain('"images"')
expect(single.text()).not.toContain('"status"')
})
})
describe('Notes', () => {
it('renders the format, image-prefix and CORS notes', async () => {
const wrapper = await mountIntegration()
const notes = section(wrapper, 'notes').text()
expect(notes).toContain(
'Article routes accept ?format=markdown, ?format=html or ?format=json to choose how body is returned. With json, body is the stored JSON structure. Articles have body_format: markdown by default.',
)
expect(notes).toContain(
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.',
)
expect(notes).toContain(
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
)
})
})
+93 -11
View File
@@ -823,6 +823,43 @@ async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy', exact: true }).first().click()
}
function integrationState(overrides: Partial<VisualState> = {}): VisualState {
return {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
...overrides,
}
}
async function scrollToAnchor(
page: Page,
anchor: string,
offsets?: { desktop?: number; mobile?: number },
) {
const mobile = (page.viewportSize()?.width ?? 0) < 1024
const offset = mobile ? offsets?.mobile : offsets?.desktop
await page.evaluate(
({ id, offset }) => {
const target = document.querySelector(`[data-anchor="${id}"]`)
if (!target) return
if (offset === undefined) {
target.scrollIntoView({ block: 'start' })
return
}
let scroller = target.parentElement
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
if (!scroller) return
scroller.scrollTop +=
target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset
},
{ id: anchor, offset },
)
await page.waitForTimeout(150)
}
const acceptInvitePath = '/invite/accept?c=abc123'
const acceptInviteEmail = 'lea.martin@renew.example'
@@ -2472,17 +2509,62 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'integration-api-keys': apiKeysStates,
integration: {
Default: {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
},
Copied: {
path: '/integration',
origin: integrationOrigin,
api: { 'GET /v1/auth/me': verifiedSession },
steps: clickBaseUrlCopy,
},
'1. Default': integrationState(),
'2. Group scrolled, one card expanded': integrationState({
steps: async (page) => {
await page.getByRole('button', { name: /^POST \/v1\/media\/\. / }).click()
await page
.locator('[data-anchor="ep-med-up"]')
.getByRole('tab', { name: 'curl', exact: true })
.click()
await scrollToAnchor(page, 'ep-med-up', { desktop: 126, mobile: 83 })
},
exception: {
maxPercent: 37.5,
reason: 'The new Query parameters block adds about 70 px above Request body, which shifts every row below the card, and the captions use the real server strings instead of the board samples',
approvedBy: 'Florian Sylvain',
},
}),
'3a. Copied: base URL': integrationState({ steps: clickBaseUrlCopy }),
'3b. Copied: CodeBlock': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'auth')
await page.getByRole('button', { name: 'Copy Shell example' }).click()
await page.mouse.move(0, 0)
},
}),
'4. Authentication': integrationState({
steps: (page) => scrollToAnchor(page, 'auth'),
}),
'5. Session only': integrationState({
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: 55 }),
exception: {
maxPercent: 12,
reason: 'The banner names the real routes and wraps differently, a 13th row (DELETE /v1/auth/register) and the login-family captions are new, and a Public auth flow block follows',
approvedBy: 'Florian Sylvain',
},
}),
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 48, mobile: 47 }),
exception: {
maxPercent: 12.5,
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board scroll position varies between captures',
approvedBy: 'Florian Sylvain',
},
}),
'7. Navigation': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'media', { desktop: 14 })
if ((page.viewportSize()?.width ?? 0) < 1024) {
await page.getByRole('button', { name: 'Jump to section' }).click()
}
},
exception: {
maxPercent: 12.5,
reason: 'The board frame scroll position varies by 18px between captures, so the desktop diff flickers between about 8 % and 12 %',
approvedBy: 'Florian Sylvain',
},
}),
},
articles: {
Default: {