feat: integration page (PART 3/3)
This commit is contained in:
1 parent
514a082c96
commit
acd392c29d
20 files changed
+3115
-409
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -6,7 +6,7 @@ export function useShikiHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighter({
|
||||
themes: ['github-light', 'github-dark'],
|
||||
langs: ['javascript'],
|
||||
langs: ['javascript', 'shellscript', 'json'],
|
||||
})
|
||||
}
|
||||
return highlighterPromise
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user