Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Every tool in that table is good at what it targets — the honest version with
| Storage configuration | [piwitests.github.io/storage](https://piwitests.github.io/storage) |
| Deployment | [piwitests.github.io/deployment](https://piwitests.github.io/deployment) |

The running dashboard also serves interactive API docs (Scalar) at `/docs`.
The running dashboard also serves self-contained interactive API docs at `/docs`, rendered in-app from the OpenAPI spec — no external CDN, so they work offline / air-gapped.

## Community & support

Expand Down
170 changes: 170 additions & 0 deletions application/app/components/docs/ApiOperation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<script setup lang="ts">
import type { FlatOperation, OpenApiSpec } from '~/utils/openapi';

const props = defineProps<{
item: FlatOperation;
spec?: OpenApiSpec | null;
}>();

const open = ref(false);

const requiresAuth = computed(() => operationRequiresAuth(props.item.operation, props.spec));
const isPublic = computed(() => operationIsPublic(props.item.operation));
const roleReq = computed(() => routeRoleRequirement(props.item.operation));

// Full readable access requirement shown in the expanded body.
const accessLabel = computed(() => {
if (isPublic.value) return 'Public — no authentication required';
if (roleReq.value) return roleReq.value.label;
if (requiresAuth.value) return 'Requires authentication';
return null;
});

const pathParams = computed(() => props.item.parameters.filter((p) => p.in === 'path'));
const queryParams = computed(() => props.item.parameters.filter((p) => p.in === 'query'));
const headerParams = computed(() => props.item.parameters.filter((p) => p.in === 'header'));

const requestBodyEntries = computed(() =>
Object.entries(props.item.operation.requestBody?.content ?? {}).map(([contentType, media]) => ({
contentType,
schema: resolveSchema(media.schema, props.spec),
})),
);

const responses = computed(() =>
Object.entries(props.item.operation.responses ?? {}).map(([status, response]) => ({
status,
description: response.description,
entries: Object.entries(response.content ?? {}).map(([contentType, media]) => ({
contentType,
schema: resolveSchema(media.schema, props.spec),
})),
})),
);

// Split the path so `{param}` segments can be visually distinguished.
const pathSegments = computed(() =>
props.item.path.split(/(\{[^}]+\})/g).map((text) => ({ text, isParam: text.startsWith('{') })),
);

function responseColor(status: string): string {
if (status.startsWith('2')) return 'text-success';
if (status.startsWith('3')) return 'text-info';
if (status.startsWith('4')) return 'text-warning';
if (status.startsWith('5')) return 'text-error';
return 'text-muted';
}
</script>

<template>
<div :id="item.anchor" class="border border-default rounded-lg bg-elevated/20 scroll-mt-20">
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2.5 text-left cursor-pointer"
:aria-expanded="open"
@click="open = !open"
>
<UBadge
:color="methodBadgeColor(item.method)"
variant="subtle"
class="font-mono uppercase shrink-0 w-16 justify-center"
>
{{ item.method }}
</UBadge>
<code class="font-mono text-sm text-highlighted break-all">
<span v-for="(seg, i) in pathSegments" :key="i" :class="seg.isParam ? 'text-warning' : ''">{{ seg.text }}</span>
</code>
<span class="text-sm text-muted truncate hidden sm:inline flex-1">{{ item.operation.summary }}</span>
<UBadge
v-if="roleReq?.elevated"
color="warning"
variant="subtle"
size="sm"
icon="i-lucide-shield"
class="shrink-0 ml-auto sm:ml-0"
:title="`Requires role: ${roleReq.label}`"
>
{{ roleReq.shortLabel }}
</UBadge>
<UBadge v-else-if="isPublic" color="neutral" variant="subtle" size="sm" class="shrink-0 ml-auto sm:ml-0">
Public
</UBadge>
<UIcon
v-else-if="requiresAuth"
name="i-lucide-lock"
class="size-3.5 text-dimmed shrink-0 ml-auto sm:ml-0"
title="Requires authentication"
/>
<UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-4 text-dimmed shrink-0" />
</button>

<div v-if="open" class="px-3 pb-3 pt-1 space-y-4 border-t border-default">
<p v-if="item.operation.summary" class="text-sm font-medium text-highlighted sm:hidden">
{{ item.operation.summary }}
</p>
<p v-if="item.operation.description" class="text-sm text-muted">{{ item.operation.description }}</p>

<!-- Access / required role -->
<div v-if="accessLabel" class="flex items-center gap-1.5 text-xs">
<UIcon :name="roleReq?.elevated ? 'i-lucide-shield' : 'i-lucide-users'" class="size-3.5 text-dimmed shrink-0" />
<span class="text-dimmed">Access</span>
<span class="text-muted font-medium">{{ accessLabel }}</span>
</div>

<!-- Parameters -->
<template
v-for="group in [
{ label: 'Path parameters', params: pathParams },
{ label: 'Query parameters', params: queryParams },
{ label: 'Header parameters', params: headerParams },
]"
:key="group.label"
>
<div v-if="group.params.length" class="space-y-1.5">
<h4 class="text-xs font-semibold uppercase tracking-wide text-dimmed">{{ group.label }}</h4>
<div class="space-y-2">
<div v-for="param in group.params" :key="param.name" class="text-sm">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<code class="font-mono text-primary">{{ param.name }}</code>
<span class="text-xs text-muted font-mono">{{ schemaTypeLabel(param.schema) }}</span>
<span v-if="param.required" class="text-xs font-medium text-error">required</span>
</div>
<p v-if="param.description" class="text-muted mt-0.5">{{ param.description }}</p>
<p v-if="param.schema?.enum?.length" class="text-xs text-dimmed mt-0.5">
Allowed values:
<span class="font-mono">{{ param.schema.enum.map((v) => JSON.stringify(v)).join(', ') }}</span>
</p>
</div>
</div>
</div>
</template>

<!-- Request body -->
<div v-if="requestBodyEntries.length" class="space-y-1.5">
<h4 class="text-xs font-semibold uppercase tracking-wide text-dimmed">Request body</h4>
<div v-for="entry in requestBodyEntries" :key="entry.contentType" class="space-y-1.5">
<code class="text-xs text-dimmed font-mono">{{ entry.contentType }}</code>
<ApiSchema v-if="entry.schema" :schema="entry.schema" :spec="spec" />
</div>
</div>

<!-- Responses -->
<div v-if="responses.length" class="space-y-1.5">
<h4 class="text-xs font-semibold uppercase tracking-wide text-dimmed">Responses</h4>
<div v-for="response in responses" :key="response.status" class="text-sm">
<div class="flex items-baseline gap-2">
<code class="font-mono font-semibold" :class="responseColor(response.status)">{{ response.status }}</code>
<span class="text-muted">{{ response.description }}</span>
</div>
<div v-for="entry in response.entries" :key="entry.contentType" class="mt-1 pl-3 border-l border-default">
<code class="text-xs text-dimmed font-mono">{{ entry.contentType }}</code>
<ApiSchema v-if="entry.schema" :schema="entry.schema" :spec="spec" class="mt-1" />
</div>
</div>
</div>

<!-- Interactive console + code samples -->
<ApiTryIt :item="item" :spec="spec" />
</div>
</div>
</template>
130 changes: 130 additions & 0 deletions application/app/components/docs/ApiReference.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { OpenApiSpec } from '~/utils/openapi';

const props = defineProps<{
spec: OpenApiSpec;
specUrl: string;
}>();

const { copy, copied } = useCopy();

const query = ref('');

const groups = computed(() => groupOperationsByTag(props.spec));

const operationCount = computed(() => groups.value.reduce((sum, group) => sum + group.operations.length, 0));

const filteredGroups = computed(() => {
const needle = query.value.trim().toLowerCase();
if (!needle) return groups.value;
return groups.value
.map((group) => ({
tag: group.tag,
operations: group.operations.filter((op) => {
const haystack = `${op.method} ${op.path} ${op.operation.summary ?? ''} ${op.operation.description ?? ''} ${op.tag}`;
return haystack.toLowerCase().includes(needle);
}),
}))
.filter((group) => group.operations.length > 0);
});

const baseUrl = computed(() => props.spec.servers?.[0]?.url ?? '');

function tagAnchor(tag: string): string {
return `tag-${tag
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')}`;
}
</script>

<template>
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<!-- Overview -->
<header class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<h1 class="text-xl font-semibold text-highlighted">{{ spec.info?.title ?? 'API Reference' }}</h1>
<UBadge v-if="spec.info?.version" color="neutral" variant="subtle" class="font-mono">
v{{ spec.info.version }}
</UBadge>
</div>
<p v-if="spec.info?.description" class="text-sm text-muted">{{ spec.info.description }}</p>

<div v-if="baseUrl" class="flex items-center gap-2 text-sm">
<span class="text-dimmed">Base URL</span>
<UButton
:label="baseUrl"
color="neutral"
variant="subtle"
size="xs"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
class="font-mono"
@click="copy(baseUrl, { toast: true })"
/>
</div>

<div class="flex flex-wrap items-center gap-2">
<UButton
:to="specUrl"
target="_blank"
external
color="neutral"
variant="outline"
size="xs"
icon="i-lucide-file-json"
label="Raw OpenAPI spec"
/>
<span class="text-xs text-dimmed">{{ operationCount }} endpoints</span>
</div>

<UAlert
color="info"
variant="subtle"
icon="i-lucide-terminal"
title="Windows users"
description="curl examples below use Unix syntax — run them in Git Bash / WSL, or use PowerShell's Invoke-RestMethod instead."
/>
</header>

<!-- Filter -->
<UInput
v-model="query"
icon="i-lucide-search"
placeholder="Filter endpoints by path, method, or description…"
size="md"
class="w-full"
:ui="{ root: 'sticky top-0 z-10' }"
/>

<!-- Tag jump nav -->
<div v-if="!query && groups.length > 1" class="flex flex-wrap gap-1.5">
<a
v-for="group in groups"
:key="group.tag"
:href="`#${tagAnchor(group.tag)}`"
class="text-xs px-2 py-1 rounded-md bg-elevated text-muted hover:text-highlighted hover:bg-accented transition-colors"
>
{{ group.tag }}
<span class="text-dimmed">{{ group.operations.length }}</span>
</a>
</div>

<!-- Operations grouped by tag -->
<div v-if="filteredGroups.length" class="space-y-8">
<section
v-for="group in filteredGroups"
:key="group.tag"
:id="tagAnchor(group.tag)"
class="space-y-2 scroll-mt-4"
>
<h2 class="text-sm font-semibold uppercase tracking-wide text-dimmed">
{{ group.tag }}
<span class="text-dimmed/60">({{ group.operations.length }})</span>
</h2>
<ApiOperation v-for="op in group.operations" :key="op.anchor" :item="op" :spec="spec" />
</section>
</div>

<EmptyState v-else icon="i-lucide-search-x" text="No endpoints match your filter." />
</div>
</template>
68 changes: 68 additions & 0 deletions application/app/components/docs/ApiSchema.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { JsonSchema, OpenApiSpec } from '~/utils/openapi';

const props = withDefaults(
defineProps<{
schema: JsonSchema;
spec?: OpenApiSpec | null;
depth?: number;
}>(),
{ spec: null, depth: 0 },
);

const resolved = computed(() => resolveSchema(props.schema, props.spec) ?? props.schema);

interface PropertyRow {
name: string;
required: boolean;
schema: JsonSchema;
}

const properties = computed<PropertyRow[]>(() => {
const object = resolved.value.type === 'array' ? resolveSchema(resolved.value.items, props.spec) : resolved.value;
const entries = object?.properties;
if (!entries) return [];
const required = new Set(object?.required ?? []);
return Object.entries(entries).map(([name, schema]) => ({
name,
required: required.has(name),
schema: resolveSchema(schema, props.spec) ?? schema,
}));
});

// Guard against pathological / self-referential schemas.
const canRecurse = computed(() => (props.depth ?? 0) < 6);

function childHasShape(schema: JsonSchema): boolean {
const inner = schema.type === 'array' ? resolveSchema(schema.items, props.spec) : schema;
return Boolean(inner?.properties && Object.keys(inner.properties).length > 0);
}

function enumValues(schema: JsonSchema): string {
return (schema.enum ?? []).map((value) => JSON.stringify(value)).join(', ');
}
</script>

<template>
<div class="space-y-2">
<div v-for="prop in properties" :key="prop.name" class="text-sm">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<code class="font-mono text-primary">{{ prop.name }}</code>
<span class="text-xs text-muted font-mono">{{ schemaTypeLabel(prop.schema) }}</span>
<span v-if="prop.schema.format" class="text-xs text-dimmed font-mono">({{ prop.schema.format }})</span>
<span v-if="prop.required" class="text-xs font-medium text-error">required</span>
</div>
<p v-if="prop.schema.description" class="text-muted mt-0.5">{{ prop.schema.description }}</p>
<p v-if="prop.schema.enum?.length" class="text-xs text-dimmed mt-0.5">
Allowed values: <span class="font-mono">{{ enumValues(prop.schema) }}</span>
</p>
<ApiSchema
v-if="canRecurse && childHasShape(prop.schema)"
:schema="prop.schema"
:spec="spec"
:depth="(depth ?? 0) + 1"
class="mt-1.5 border-l border-default pl-3"
/>
</div>
</div>
</template>
Loading