Files
RenewCMS/web/src/views/IntegrationView.vue
T

431 lines
14 KiB
Vue

<script setup lang="ts">
import { t, translate } from '@/i18n'
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'))
function copyBaseUrl() {
copy(origin.value)
}
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' },
{
label: 'fetch',
value: 'fetch',
code: examples.fetch,
lang: 'javascript',
title: 'JavaScript',
},
]
})
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(
'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 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(
'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 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>
<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="[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>
<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>
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
</div>
</DashboardShell>
</template>