feat: auto-generating OpenAPI docs with Scalar UI

This commit is contained in:
jakeaturner 2026-07-08 17:11:09 +00:00
parent 6a4f02dd46
commit 617391cf05
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
10 changed files with 3355 additions and 343 deletions

View File

@ -0,0 +1,66 @@
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
import type { HttpContext } from '@adonisjs/core/http'
import { buildOpenApiDocument } from '#start/openapi/generator'
const require = createRequire(import.meta.url)
/**
* The self-contained Scalar browser bundle, read from the installed
* `@scalar/api-reference` package and cached in memory. Served from this app
* (not a CDN) so the API reference works on an offline appliance.
*/
let scalarBundle: string | null = null
function getScalarBundle(): string {
if (scalarBundle === null) {
const entry = require.resolve('@scalar/api-reference')
const marker = '/node_modules/@scalar/api-reference/'
const root = entry.slice(0, entry.lastIndexOf(marker) + marker.length - 1)
scalarBundle = readFileSync(`${root}/dist/browser/standalone.js`, 'utf-8')
}
return scalarBundle
}
/**
* The reference page mounts Scalar against our locally served bundle and spec.
* Scalar auto-detects the `#api-reference` element and reads `data-url`.
*/
const REFERENCE_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nomad Admin API Reference</title>
</head>
<body>
<script id="api-reference" data-url="/api/openapi.json"></script>
<script src="/reference/assets/standalone.js"></script>
</body>
</html>`
export default class OpenApiController {
/**
* The generated OpenAPI 3.1 document.
*/
async spec({ response }: HttpContext) {
return response.json(buildOpenApiDocument())
}
/**
* The interactive Scalar API reference page.
*/
async reference({ response }: HttpContext) {
return response.header('content-type', 'text/html').send(REFERENCE_HTML)
}
/**
* The locally bundled Scalar JS (kept off any CDN for offline use).
*/
async standalone({ response }: HttpContext) {
return response
.header('content-type', 'application/javascript; charset=utf-8')
.header('cache-control', 'public, max-age=86400')
.send(getScalarBundle())
}
}

View File

@ -0,0 +1,33 @@
/*
|--------------------------------------------------------------------------
| Chat response schemas
|--------------------------------------------------------------------------
|
| Shapes mirror the serialized `ChatSession` / `ChatMessage` Lucid models
| (app/models/chat_session.ts, app/models/chat_message.ts). `messages` is only
| present when the relation is preloaded, so it is optional.
|
*/
import vine from '@vinejs/vine'
const chatMessage = vine.object({
id: vine.number(),
session_id: vine.number(),
role: vine.enum(['system', 'user', 'assistant'] as const),
content: vine.string(),
created_at: vine.string(),
updated_at: vine.string(),
})
const chatSession = vine.object({
id: vine.number(),
title: vine.string(),
model: vine.string().nullable(),
created_at: vine.string(),
updated_at: vine.string(),
messages: vine.array(chatMessage).optional(),
})
export const chatSessionResponse = vine.compile(chatSession)
export const chatSessionListResponse = vine.compile(vine.array(chatSession.clone()))
export const chatMessageResponse = vine.compile(chatMessage.clone())

View File

@ -0,0 +1,37 @@
/*
|--------------------------------------------------------------------------
| Shared response schemas
|--------------------------------------------------------------------------
|
| Response schemas are authored as VineJS validators purely so the OpenAPI
| generator can turn them into documented response bodies via `toJSONSchema()`.
| They are not (currently) used to validate outgoing responses though they
| could be asserted against in tests.
|
| Only add a schema here once its shape is verified against the controller /
| model it documents; a wrong response schema is worse than none.
|
*/
import vine from '@vinejs/vine'
/** `{ status: 'ok' }` health probes. */
export const healthResponse = vine.compile(
vine.object({
status: vine.string(),
})
)
/** The `{ error: '...' }` envelope returned on most failure paths. */
export const errorResponse = vine.compile(
vine.object({
error: vine.string(),
})
)
/** The `{ success, message? }` envelope used by many mutation endpoints. */
export const successMessageResponse = vine.compile(
vine.object({
success: vine.boolean(),
message: vine.string().optional(),
})
)

View File

@ -4,6 +4,19 @@ N.O.M.A.D. exposes a REST API for all operations. All endpoints are under `/api/
---
## Interactive reference
The full, always-current endpoint reference is generated directly from the application's
routes and validators and served as an interactive [Scalar](https://scalar.com) UI:
- **[/reference](/reference)** — browse every endpoint, request/response schema, and try calls live
- **[/api/openapi.json](/api/openapi.json)** — the raw OpenAPI 3.1 document (import into Postman, Insomnia, codegen, etc.)
Because it is derived from the same VineJS validators the API validates against, it never drifts
from the implementation. Prefer it over any hand-written endpoint list.
---
## Conventions
**Base URL:** `http://<your-server>/api`
@ -15,195 +28,5 @@ N.O.M.A.D. exposes a REST API for all operations. All endpoints are under `/api/
**Async pattern:** Submit a job → receive an ID → poll a status endpoint until complete.
---
## Health
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Returns `{ "status": "ok" }` |
---
## System
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/system/info` | CPU, memory, disk, and platform info |
| GET | `/api/system/internet-status` | Check internet connectivity |
| GET | `/api/system/debug-info` | Detailed debug information |
| GET | `/api/system/latest-version` | Check for the latest N.O.M.A.D. version |
| POST | `/api/system/update` | Trigger a system update |
| GET | `/api/system/update/status` | Get update progress |
| GET | `/api/system/update/logs` | Get update operation logs |
| GET | `/api/system/settings` | Get a setting value (query param: `key`) |
| PATCH | `/api/system/settings` | Update a setting (`{ key, value }`) |
| POST | `/api/system/subscribe-release-notes` | Subscribe an email to release notes |
### Services
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/system/services` | List all services with status |
| POST | `/api/system/services/install` | Install a service |
| POST | `/api/system/services/force-reinstall` | Force reinstall a service |
| POST | `/api/system/services/affect` | Start, stop, or restart a service (body: `{ name, action }`) |
| POST | `/api/system/services/check-updates` | Check for available service updates |
| POST | `/api/system/services/update` | Update a service to a specific version |
| GET | `/api/system/services/:name/available-versions` | List available versions for a service |
---
## AI Chat
### Models
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/ollama/models` | List available models (supports filtering, sorting, pagination) |
| GET | `/api/ollama/installed-models` | List locally installed models |
| POST | `/api/ollama/models` | Download a model (async, returns job) |
| DELETE | `/api/ollama/models` | Delete an installed model |
### Chat
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/ollama/chat` | Send a chat message. Supports streaming (SSE) and RAG context injection. Body: `{ model, messages, stream?, useRag? }` |
| GET | `/api/chat/suggestions` | Get suggested chat prompts |
### Remote Ollama
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/ollama/configure-remote` | Configure a remote Ollama or LM Studio instance |
| GET | `/api/ollama/remote-status` | Check remote Ollama connection status |
### Chat Sessions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/chat/sessions` | List all chat sessions |
| POST | `/api/chat/sessions` | Create a new session |
| GET | `/api/chat/sessions/:id` | Get a session with its messages |
| PUT | `/api/chat/sessions/:id` | Update session metadata (title, etc.) |
| DELETE | `/api/chat/sessions/:id` | Delete a session |
| DELETE | `/api/chat/sessions/all` | Delete all sessions |
| POST | `/api/chat/sessions/:id/messages` | Add a message to a session |
**Streaming:** The `/api/ollama/chat` endpoint supports Server-Sent Events (SSE) when `stream: true` is passed. Connect using `EventSource` or `fetch` with a streaming reader.
---
## Knowledge Base (RAG)
Upload documents to enable AI-powered retrieval during chat.
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/rag/upload` | Upload a file for embedding (async, 202 response) |
| GET | `/api/rag/files` | List stored RAG files |
| DELETE | `/api/rag/files` | Delete a file (query param: `source`) |
| GET | `/api/rag/active-jobs` | List active embedding jobs |
| GET | `/api/rag/job-status` | Get status for a specific file embedding job |
| GET | `/api/rag/failed-jobs` | List failed embedding jobs |
| DELETE | `/api/rag/failed-jobs` | Clean up failed jobs and delete associated files |
| POST | `/api/rag/sync` | Scan storage and sync database with filesystem |
---
## ZIM Files (Offline Content)
ZIM files provide offline Wikipedia, books, and other content via Kiwix.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/zim/list` | List locally stored ZIM files |
| GET | `/api/zim/list-remote` | List remote ZIM files (paginated, supports search) |
| GET | `/api/zim/curated-categories` | List curated categories with Essential/Standard/Comprehensive tiers |
| POST | `/api/zim/download-remote` | Download a remote ZIM file (async) |
| POST | `/api/zim/download-category-tier` | Download a full category tier |
| DELETE | `/api/zim/:filename` | Delete a local ZIM file |
### Wikipedia
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/zim/wikipedia` | Get current Wikipedia selection state |
| POST | `/api/zim/wikipedia/select` | Select a Wikipedia edition and tier |
---
## Maps
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/maps/regions` | List available map regions |
| GET | `/api/maps/styles` | Get map styles JSON |
| GET | `/api/maps/curated-collections` | List curated map collections |
| POST | `/api/maps/fetch-latest-collections` | Fetch latest collection metadata from source |
| POST | `/api/maps/download-base-assets` | Download base map assets |
| POST | `/api/maps/download-remote` | Download a remote map file (async) |
| POST | `/api/maps/download-remote-preflight` | Check download size/info before starting |
| POST | `/api/maps/download-collection` | Download an entire collection by slug (async) |
| DELETE | `/api/maps/:filename` | Delete a local map file |
### Map Markers
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/maps/markers` | List map markers |
| POST | `/api/maps/markers` | Add map marker (body: {"name": "Test Marker", "notes": "Example note", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) |
| PATCH | `/api/maps/markers/{id}` | Update a map marker (body: {"name": "Test Marker", "notes": "Example note", "longitude": 0.0, "latitude": 0.0, "color": "yellow", "marker_type": "pin"} ) fields that don't change can be omitted|
| DELETE | `/api/maps/markers/{id}` | Delete a map marker |
---
## Downloads
Manage background download jobs for maps, ZIM files, and models.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/downloads/jobs` | List all download jobs |
| GET | `/api/downloads/jobs/:filetype` | List jobs filtered by type (`zim`, `map`, etc.) |
| DELETE | `/api/downloads/jobs/:jobId` | Cancel and remove a download job |
---
## Benchmarks
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/benchmark/run` | Run a benchmark (`full`, `system`, or `ai`; can be async) |
| POST | `/api/benchmark/run/system` | Run system-only benchmark |
| POST | `/api/benchmark/run/ai` | Run AI-only benchmark |
| GET | `/api/benchmark/status` | Get current benchmark status (`idle` or `running`) |
| GET | `/api/benchmark/results` | Get all benchmark results |
| GET | `/api/benchmark/results/latest` | Get the most recent result |
| GET | `/api/benchmark/results/:id` | Get a specific result |
| POST | `/api/benchmark/submit` | Submit a result to the central repository |
| POST | `/api/benchmark/builder-tag` | Update builder tag metadata for a result |
| GET | `/api/benchmark/comparison` | Get comparison stats from the repository |
| GET | `/api/benchmark/settings` | Get benchmark settings |
| POST | `/api/benchmark/settings` | Update benchmark settings |
---
## Easy Setup & Content Updates
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/easy-setup/curated-categories` | List curated content categories for setup wizard |
| POST | `/api/manifests/refresh` | Refresh manifest caches (`zim_categories`, `maps`, `wikipedia`) |
| POST | `/api/content-updates/check` | Check for available collection updates |
| POST | `/api/content-updates/apply` | Apply a single content update |
| POST | `/api/content-updates/apply-all` | Apply multiple content updates |
---
## Documentation
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/docs/list` | List all available documentation files |
**Streaming:** The `/api/ollama/chat` endpoint supports Server-Sent Events (SSE) when `stream: true`
is passed. Connect using `EventSource` or `fetch` with a streaming reader.

2213
admin/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,7 @@
"@openzim/libzim": "4.0.0",
"@protomaps/basemaps": "5.7.0",
"@qdrant/js-client-rest": "1.16.2",
"@scalar/api-reference": "^1.62.4",
"@tabler/icons-react": "3.36.1",
"@tailwindcss/vite": "4.3.0",
"@tanstack/react-query": "5.90.20",
@ -89,7 +90,7 @@
"@uppy/dashboard": "5.1.0",
"@uppy/react": "5.1.1",
"@uppy/xhr-upload": "5.2.0",
"@vinejs/vine": "3.0.1",
"@vinejs/vine": "^4.2.0",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.5.0",
"axios": "1.17.0",

View File

@ -0,0 +1,75 @@
/*
|--------------------------------------------------------------------------
| OpenAPI route documentation helper
|--------------------------------------------------------------------------
|
| `documented()` wraps a route defined in `start/routes.ts` and records the
| schemas that describe it, so the OpenAPI generator can derive the spec from
| the same VineJS validators the controllers already validate against.
|
| The helper stores the descriptor against the live `Route` instance and
| returns the route unchanged, so it stays chainable (e.g. `.middleware(...)`)
| and the enclosing group's `.prefix()` still applies. The generator reads the
| final, prefixed pattern back off the same instance at build time.
|
*/
import type { Route } from '@adonisjs/core/http'
import type { VineValidator } from '@vinejs/vine'
/**
* A compiled VineJS validator, as returned by `vine.compile(...)`. The schema
* and metadata generics are irrelevant here we only ever call `toJSONSchema()`.
*/
export type AnyValidator = VineValidator<any, any>
/**
* Describes a single documented response. A bare validator is shorthand for
* `{ schema: validator }` with a default description.
*/
export interface ResponseDoc {
description?: string
schema?: AnyValidator
}
export interface DocDescriptor {
/** One-line summary shown as the operation title in Scalar. */
summary?: string
/** Longer prose description (Markdown supported by Scalar). */
description?: string
/** Tags used to group operations in the UI (usually the domain, e.g. `zim`). */
tags?: string[]
/** Validator for the JSON request body. */
request?: AnyValidator
/** Validator for the query string (each top-level property → a query param). */
query?: AnyValidator
/**
* Validator for route params. Follows the AdonisJS convention of wrapping
* fields under a top-level `params` object (e.g. `filenameParamValidator`);
* the generator unwraps it. A flat object is also accepted.
*/
params?: AnyValidator
/** Response schemas keyed by HTTP status code. */
responses?: Record<number | string, ResponseDoc | AnyValidator>
}
/**
* Registry of documented routes. Keyed by the live `Route` instance so the
* generator can read each route's final pattern/methods via `route.toJSON()`.
*/
const registry = new Map<Route, DocDescriptor>()
/**
* Attach OpenAPI documentation to a route and return the route for chaining.
*/
export function documented(route: Route, descriptor: DocDescriptor): Route {
registry.set(route, descriptor)
return route
}
/**
* The documented-route registry, consumed by the generator and the
* completeness test.
*/
export function getDocRegistry(): ReadonlyMap<Route, DocDescriptor> {
return registry
}

View File

@ -0,0 +1,234 @@
/*
|--------------------------------------------------------------------------
| OpenAPI document generator
|--------------------------------------------------------------------------
|
| Assembles an OpenAPI 3.1 document from the `documented()` registry. Request,
| query, param, and response schemas are produced by VineJS's native
| `validator.toJSONSchema()` (JSON Schema Draft 7), which is a compatible
| subset of the JSON Schema dialect OpenAPI 3.1 uses so almost no
| transformation is required beyond stripping the `$schema` keyword.
|
| The document is built lazily and cached, since routes and validators are
| static once the app has booted.
|
*/
import { SystemService } from '#services/system_service'
import { getDocRegistry, type AnyValidator, type DocDescriptor, type ResponseDoc } from './documented.js'
type JsonObject = Record<string, any>
/**
* OpenAPI methods we emit operations for. `HEAD` (auto-added by Adonis
* alongside `GET`) and `OPTIONS` are skipped.
*/
const DOCUMENTED_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete'])
const BODY_METHODS = new Set(['post', 'put', 'patch', 'delete'])
let cached: JsonObject | null = null
/**
* Build (or return the cached) OpenAPI 3.1 document for all documented routes.
*/
export function buildOpenApiDocument(force = false): JsonObject {
if (cached && !force) {
return cached
}
const paths: JsonObject = {}
const tagSet = new Set<string>()
for (const [route, descriptor] of getDocRegistry()) {
const json = route.toJSON()
const oaPath = toOpenApiPath(json.pattern)
const methods = json.methods.map((m) => m.toLowerCase()).filter((m) => DOCUMENTED_METHODS.has(m))
for (const method of methods) {
const operation = buildOperation(descriptor, oaPath, method)
for (const tag of operation.tags ?? []) {
tagSet.add(tag)
}
paths[oaPath] = paths[oaPath] ?? {}
paths[oaPath][method] = operation
}
}
cached = {
openapi: '3.1.0',
info: {
title: 'Nomad Admin API',
description:
'HTTP API for the Nomad admin appliance. Generated from the application ' +
'routes and VineJS validators — see `/reference` for the interactive UI.',
version: SystemService.getAppVersion(),
},
servers: [{ url: '/', description: 'This appliance' }],
tags: [...tagSet].sort().map((name) => ({ name })),
paths,
}
return cached
}
/**
* Clear the cached document (used after HMR or in tests that need a rebuild).
*/
export function resetOpenApiCache(): void {
cached = null
}
/**
* Build a single OpenAPI operation object.
*/
function buildOperation(descriptor: DocDescriptor, oaPath: string, method: string): JsonObject {
const operation: JsonObject = {
operationId: operationId(method, oaPath),
}
if (descriptor.summary) operation.summary = descriptor.summary
if (descriptor.description) operation.description = descriptor.description
if (descriptor.tags?.length) operation.tags = descriptor.tags
const parameters = [
...pathParameters(oaPath, descriptor.params),
...queryParameters(descriptor.query),
]
if (parameters.length) operation.parameters = parameters
if (descriptor.request && BODY_METHODS.has(method)) {
const schema = normalizeSchema(descriptor.request.toJSONSchema())
operation.requestBody = {
required: Array.isArray(schema.required) && schema.required.length > 0,
content: { 'application/json': { schema } },
}
}
operation.responses = buildResponses(descriptor.responses)
return operation
}
/**
* Path parameters come from the pattern itself (always required); their schema
* is taken from the `params` validator when provided, otherwise defaulted to a
* plain string.
*/
function pathParameters(oaPath: string, paramsValidator?: AnyValidator): JsonObject[] {
const names = [...oaPath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])
if (names.length === 0) return []
const schemas = paramsValidator ? unwrapParamsSchema(paramsValidator) : {}
return names.map((name) => ({
name,
in: 'path',
required: true,
schema: schemas[name] ? normalizeSchema(schemas[name]) : { type: 'string' },
}))
}
/**
* Each top-level property of the query validator becomes a query parameter.
*/
function queryParameters(queryValidator?: AnyValidator): JsonObject[] {
if (!queryValidator) return []
const schema = queryValidator.toJSONSchema()
const properties: JsonObject = schema.properties ?? {}
const required: string[] = Array.isArray(schema.required) ? schema.required : []
return Object.entries(properties).map(([name, propSchema]) => ({
name,
in: 'query',
required: required.includes(name),
schema: normalizeSchema(propSchema as JsonObject),
}))
}
/**
* Build the `responses` object. Defaults to a bare 200 when none are declared.
*/
function buildResponses(responses?: DocDescriptor['responses']): JsonObject {
if (!responses || Object.keys(responses).length === 0) {
return { '200': { description: 'Successful response' } }
}
const out: JsonObject = {}
for (const [code, value] of Object.entries(responses)) {
const doc = normalizeResponse(value)
const entry: JsonObject = { description: doc.description ?? 'Response' }
if (doc.schema) {
entry.content = { 'application/json': { schema: normalizeSchema(doc.schema.toJSONSchema()) } }
}
out[code] = entry
}
return out
}
/**
* Accept either a bare validator or a `{ description, schema }` object.
*/
function normalizeResponse(value: ResponseDoc | AnyValidator): ResponseDoc {
if (value && typeof (value as AnyValidator).toJSONSchema === 'function') {
return { schema: value as AnyValidator }
}
return value as ResponseDoc
}
/**
* Unwrap the AdonisJS `params` convention: `filenameParamValidator` produces
* `{ properties: { params: { properties: { filename } } } }`. Return the inner
* per-param schemas. A flat validator (no `params` wrapper) is passed through.
*/
function unwrapParamsSchema(paramsValidator: AnyValidator): JsonObject {
const schema = paramsValidator.toJSONSchema() as JsonObject
const inner = schema.properties?.params ?? schema
return (inner.properties ?? {}) as JsonObject
}
/**
* Convert an AdonisJS route pattern to an OpenAPI path:
* `/api/zim/:filename` `/api/zim/{filename}`
* `/api/zim/:id?` `/api/zim/{id}`
* `/api/files/*` `/api/files/{wildcard}`
*/
function toOpenApiPath(pattern: string): string {
return pattern
.replace(/:([A-Za-z0-9_]+)\??/g, '{$1}')
.replace(/\*/g, '{wildcard}')
}
/**
* Derive a stable operationId, e.g. `get_api_zim_list`.
*/
function operationId(method: string, oaPath: string): string {
const slug = oaPath
.replace(/[{}]/g, '')
.split('/')
.filter(Boolean)
.join('_')
return slug ? `${method}_${slug}` : `${method}_root`
}
/**
* Normalize a Vine JSON Schema for OpenAPI 3.1. Vine emits clean Draft-7
* (no `$defs`/`$ref`), so we only need to strip the `$schema` keyword, which
* is not allowed on OpenAPI schema objects. Done recursively for safety.
*/
function normalizeSchema(schema: JsonObject): JsonObject {
if (Array.isArray(schema)) {
return schema.map((item) => (isObject(item) ? normalizeSchema(item) : item))
}
if (!isObject(schema)) return schema
const out: JsonObject = {}
for (const [key, value] of Object.entries(schema)) {
if (key === '$schema') continue
out[key] = isObject(value) || Array.isArray(value) ? normalizeSchema(value) : value
}
return out
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null
}

View File

@ -14,6 +14,7 @@ import EasySetupController from '#controllers/easy_setup_controller'
import HomeController from '#controllers/home_controller'
import MapsController from '#controllers/maps_controller'
import OllamaController from '#controllers/ollama_controller'
import OpenApiController from '#controllers/openapi_controller'
import RagController from '#controllers/rag_controller'
import SettingsController from '#controllers/settings_controller'
import SupplyDepotController from '#controllers/supply_depot_controller'
@ -22,6 +23,68 @@ import CollectionUpdatesController from '#controllers/collection_updates_control
import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main'
import { documented } from '#start/openapi/documented'
import {
remoteDownloadValidator,
remoteDownloadWithMetadataValidator,
remoteDownloadValidatorOptional,
filenameParamValidator,
downloadCollectionValidator,
downloadCategoryTierValidator,
selectWikipediaValidator,
applyContentUpdateValidator,
applyAllContentUpdatesValidator,
mapExtractPreflightValidator,
mapExtractValidator,
} from '#validators/common'
import {
listRemoteZimValidator,
addCustomLibraryValidator,
browseLibraryValidator,
idParamValidator,
} from '#validators/zim'
import {
getJobStatusSchema,
deleteFileSchema,
embedFileSchema,
fileSourceSchema,
estimateBatchSchema,
} from '#validators/rag'
import {
installServiceValidator,
affectServiceValidator,
subscribeToReleaseNotesValidator,
checkLatestVersionValidator,
updateServiceValidator,
preflightValidator,
setServiceAutoUpdateValidator,
preflightCustomValidator,
customAppValidator,
setServiceCustomUrlValidator,
deleteCustomAppValidator,
uninstallServiceValidator,
serviceLogsValidator,
updateCustomAppValidator,
} from '#validators/system'
import {
chatSchema,
unloadChatModelsSchema,
getAvailableModelsSchema,
} from '#validators/ollama'
import { getSettingSchema, updateSettingSchema } from '#validators/settings'
import {
createSessionSchema,
updateSessionSchema,
addMessageSchema,
} from '#validators/chat'
import { downloadJobsByFiletypeSchema, modelNameSchema } from '#validators/download'
import { runBenchmarkValidator, submitBenchmarkValidator } from '#validators/benchmark'
import { healthResponse, errorResponse } from '#validators/responses/common'
import {
chatSessionResponse,
chatSessionListResponse,
chatMessageResponse,
} from '#validators/responses/chat'
transmit.registerRoutes()
@ -35,13 +98,33 @@ router.on('/knowledge-base').redirectToPath('/chat?knowledge_base=true') // redi
router.get('/easy-setup', [EasySetupController, 'index'])
router.get('/easy-setup/complete', [EasySetupController, 'complete'])
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])
router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests'])
documented(
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories']),
{
summary: 'List curated easy-setup categories',
tags: ['easy-setup'],
}
)
documented(router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']), {
summary: 'Refresh content manifests',
tags: ['easy-setup'],
})
router
.group(() => {
router.post('/check', [CollectionUpdatesController, 'checkForUpdates'])
router.post('/apply', [CollectionUpdatesController, 'applyUpdate'])
router.post('/apply-all', [CollectionUpdatesController, 'applyAllUpdates'])
documented(router.post('/check', [CollectionUpdatesController, 'checkForUpdates']), {
summary: 'Check for available content updates',
tags: ['content-updates'],
})
documented(router.post('/apply', [CollectionUpdatesController, 'applyUpdate']), {
summary: 'Apply a content update',
tags: ['content-updates'],
request: applyContentUpdateValidator,
})
documented(router.post('/apply-all', [CollectionUpdatesController, 'applyAllUpdates']), {
summary: 'Apply all available content updates',
tags: ['content-updates'],
request: applyAllContentUpdatesValidator,
})
})
.prefix('/api/content-updates')
@ -73,171 +156,598 @@ router
router
.group(() => {
router.get('/regions', [MapsController, 'listRegions'])
router.get('/styles', [MapsController, 'styles'])
router.get('/curated-collections', [MapsController, 'listCuratedCollections'])
router.post('/fetch-latest-collections', [MapsController, 'fetchLatestCollections'])
router.post('/download-base-assets', [MapsController, 'downloadBaseAssets'])
router.post('/download-remote', [MapsController, 'downloadRemote'])
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
router.post('/download-collection', [MapsController, 'downloadCollection'])
router.get('/global-map-info', [MapsController, 'globalMapInfo'])
router.post('/download-global-map', [MapsController, 'downloadGlobalMap'])
router.get('/countries', [MapsController, 'listCountries'])
router.get('/country-groups', [MapsController, 'listCountryGroups'])
router.post('/extract-preflight', [MapsController, 'extractPreflight'])
router.post('/extract', [MapsController, 'extractRegion'])
router.get('/markers', [MapsController, 'listMarkers'])
router.post('/markers', [MapsController, 'createMarker'])
router.patch('/markers/:id', [MapsController, 'updateMarker'])
router.delete('/markers/:id', [MapsController, 'deleteMarker'])
router.delete('/:filename', [MapsController, 'delete'])
documented(router.get('/regions', [MapsController, 'listRegions']), {
summary: 'List available map regions',
tags: ['maps'],
})
documented(router.get('/styles', [MapsController, 'styles']), {
summary: 'List available map styles',
tags: ['maps'],
})
documented(router.get('/curated-collections', [MapsController, 'listCuratedCollections']), {
summary: 'List curated map collections',
tags: ['maps'],
})
documented(router.post('/fetch-latest-collections', [MapsController, 'fetchLatestCollections']), {
summary: 'Fetch the latest map collections',
tags: ['maps'],
})
documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']), {
summary: 'Download base map assets',
tags: ['maps'],
request: remoteDownloadValidatorOptional,
})
documented(router.post('/download-remote', [MapsController, 'downloadRemote']), {
summary: 'Queue a remote map download',
tags: ['maps'],
request: remoteDownloadValidator,
})
documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']), {
summary: 'Preflight a remote map download',
tags: ['maps'],
request: remoteDownloadValidator,
})
documented(router.post('/download-collection', [MapsController, 'downloadCollection']), {
summary: 'Download a map collection',
tags: ['maps'],
request: downloadCollectionValidator,
})
documented(router.get('/global-map-info', [MapsController, 'globalMapInfo']), {
summary: 'Get global map information',
tags: ['maps'],
})
documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']), {
summary: 'Download the global map',
tags: ['maps'],
})
documented(router.get('/countries', [MapsController, 'listCountries']), {
summary: 'List available countries',
tags: ['maps'],
})
documented(router.get('/country-groups', [MapsController, 'listCountryGroups']), {
summary: 'List country groups',
tags: ['maps'],
})
documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']), {
summary: 'Preflight a map region extraction',
tags: ['maps'],
request: mapExtractPreflightValidator,
})
documented(router.post('/extract', [MapsController, 'extractRegion']), {
summary: 'Extract a map region',
tags: ['maps'],
request: mapExtractValidator,
})
documented(router.get('/markers', [MapsController, 'listMarkers']), {
summary: 'List map markers',
tags: ['maps'],
})
documented(router.post('/markers', [MapsController, 'createMarker']), {
summary: 'Create a map marker',
tags: ['maps'],
})
documented(router.patch('/markers/:id', [MapsController, 'updateMarker']), {
summary: 'Update a map marker',
tags: ['maps'],
})
documented(router.delete('/markers/:id', [MapsController, 'deleteMarker']), {
summary: 'Delete a map marker',
tags: ['maps'],
})
documented(router.delete('/:filename', [MapsController, 'delete']), {
summary: 'Delete a map file',
tags: ['maps'],
params: filenameParamValidator,
})
})
.prefix('/api/maps')
router
.group(() => {
router.get('/list', [DocsController, 'list'])
documented(router.get('/list', [DocsController, 'list']), {
summary: 'List documentation pages',
tags: ['docs'],
})
})
.prefix('/api/docs')
router
.group(() => {
router.get('/jobs', [DownloadsController, 'index'])
router.get('/jobs/:filetype', [DownloadsController, 'filetype'])
router.delete('/jobs/:jobId', [DownloadsController, 'removeJob'])
router.post('/jobs/:jobId/cancel', [DownloadsController, 'cancelJob'])
documented(router.get('/jobs', [DownloadsController, 'index']), {
summary: 'List download jobs',
tags: ['downloads'],
})
documented(router.get('/jobs/:filetype', [DownloadsController, 'filetype']), {
summary: 'List download jobs by filetype',
tags: ['downloads'],
params: downloadJobsByFiletypeSchema,
})
documented(router.delete('/jobs/:jobId', [DownloadsController, 'removeJob']), {
summary: 'Remove a download job',
tags: ['downloads'],
})
documented(router.post('/jobs/:jobId/cancel', [DownloadsController, 'cancelJob']), {
summary: 'Cancel a download job',
tags: ['downloads'],
})
})
.prefix('/api/downloads')
router.get('/api/health', () => {
return { status: 'ok' }
})
documented(
router.get('/api/health', () => {
return { status: 'ok' }
}),
{ summary: 'Health check', tags: ['meta'], responses: { 200: healthResponse } }
)
// Self-generating API docs: OpenAPI spec + Scalar UI. Registered top-level so
// they stay clear of the `/docs/:slug` markdown catch-all above. The Scalar
// bundle is served from this app (not a CDN) for offline appliances.
router.get('/api/openapi.json', [OpenApiController, 'spec'])
router.get('/reference', [OpenApiController, 'reference'])
router.get('/reference/assets/standalone.js', [OpenApiController, 'standalone'])
router
.group(() => {
router.post('/chat', [OllamaController, 'chat'])
router.get('/models', [OllamaController, 'availableModels'])
router.post('/models', [OllamaController, 'dispatchModelDownload'])
router.delete('/models', [OllamaController, 'deleteModel'])
router.get('/installed-models', [OllamaController, 'installedModels'])
router.post('/unload-chat-models', [OllamaController, 'unloadChatModels'])
router.post('/configure-remote', [OllamaController, 'configureRemote'])
router.get('/remote-status', [OllamaController, 'remoteStatus'])
documented(router.post('/chat', [OllamaController, 'chat']), {
summary: 'Send a chat completion request',
tags: ['ollama'],
request: chatSchema,
})
documented(router.get('/models', [OllamaController, 'availableModels']), {
summary: 'List available models',
tags: ['ollama'],
query: getAvailableModelsSchema,
})
documented(router.post('/models', [OllamaController, 'dispatchModelDownload']), {
summary: 'Queue a model download',
tags: ['ollama'],
request: modelNameSchema,
})
documented(router.delete('/models', [OllamaController, 'deleteModel']), {
summary: 'Delete a model',
tags: ['ollama'],
request: modelNameSchema,
})
documented(router.get('/installed-models', [OllamaController, 'installedModels']), {
summary: 'List installed models',
tags: ['ollama'],
})
documented(router.post('/unload-chat-models', [OllamaController, 'unloadChatModels']), {
summary: 'Unload chat models from memory',
tags: ['ollama'],
request: unloadChatModelsSchema,
})
documented(router.post('/configure-remote', [OllamaController, 'configureRemote']), {
summary: 'Configure a remote Ollama endpoint',
tags: ['ollama'],
})
documented(router.get('/remote-status', [OllamaController, 'remoteStatus']), {
summary: 'Get remote Ollama status',
tags: ['ollama'],
})
})
.prefix('/api/ollama')
router
.group(() => {
router.get('/', [ChatsController, 'index'])
router.post('/', [ChatsController, 'store'])
router.delete('/all', [ChatsController, 'destroyAll'])
router.get('/:id', [ChatsController, 'show'])
router.put('/:id', [ChatsController, 'update'])
router.delete('/:id', [ChatsController, 'destroy'])
router.post('/:id/messages', [ChatsController, 'addMessage'])
documented(router.get('/', [ChatsController, 'index']), {
summary: 'List chat sessions',
tags: ['chat'],
responses: { 200: chatSessionListResponse },
})
documented(router.post('/', [ChatsController, 'store']), {
summary: 'Create a chat session',
tags: ['chat'],
request: createSessionSchema,
responses: {
201: { description: 'The created session', schema: chatSessionResponse },
500: errorResponse,
},
})
documented(router.delete('/all', [ChatsController, 'destroyAll']), {
summary: 'Delete all chat sessions',
tags: ['chat'],
responses: { 204: { description: 'All sessions deleted' } },
})
documented(router.get('/:id', [ChatsController, 'show']), {
summary: 'Get a chat session',
tags: ['chat'],
responses: {
200: chatSessionResponse,
404: { description: 'Session not found', schema: errorResponse },
},
})
documented(router.put('/:id', [ChatsController, 'update']), {
summary: 'Update a chat session',
tags: ['chat'],
request: updateSessionSchema,
responses: { 200: chatSessionResponse, 500: errorResponse },
})
documented(router.delete('/:id', [ChatsController, 'destroy']), {
summary: 'Delete a chat session',
tags: ['chat'],
responses: { 204: { description: 'Session deleted' } },
})
documented(router.post('/:id/messages', [ChatsController, 'addMessage']), {
summary: 'Add a message to a chat session',
tags: ['chat'],
request: addMessageSchema,
responses: {
201: { description: 'The created message', schema: chatMessageResponse },
500: errorResponse,
},
})
})
.prefix('/api/chat/sessions')
router.get('/api/chat/suggestions', [ChatsController, 'suggestions'])
documented(router.get('/api/chat/suggestions', [ChatsController, 'suggestions']), {
summary: 'List chat suggestions',
tags: ['chat'],
})
router
.group(() => {
router.post('/upload', [RagController, 'upload'])
router.get('/files', [RagController, 'getStoredFiles'])
router.get('/file-warnings', [RagController, 'getFileWarnings'])
router.delete('/files', [RagController, 'deleteFile'])
router.post('/files/embed', [RagController, 'embedFile'])
router.get('/files/content', [RagController, 'getFileContent'])
router.get('/files/download', [RagController, 'downloadFile'])
router.get('/active-jobs', [RagController, 'getActiveJobs'])
router.get('/failed-jobs', [RagController, 'getFailedJobs'])
router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs'])
router.delete('/jobs', [RagController, 'cancelAllJobs'])
router.get('/job-status', [RagController, 'getJobStatus'])
router.post('/sync', [RagController, 'scanAndSync'])
router.post('/re-embed-all', [RagController, 'reembedAll'])
router.post('/reset-and-rebuild', [RagController, 'resetAndRebuild'])
router.post('/estimate-batch', [RagController, 'estimateBatch'])
router.get('/policy-prompt-state', [RagController, 'policyPromptState'])
router.get('/health', [RagController, 'health'])
documented(router.post('/upload', [RagController, 'upload']), {
summary: 'Upload a file for RAG',
tags: ['rag'],
})
documented(router.get('/files', [RagController, 'getStoredFiles']), {
summary: 'List stored RAG files',
tags: ['rag'],
})
documented(router.get('/file-warnings', [RagController, 'getFileWarnings']), {
summary: 'List RAG file warnings',
tags: ['rag'],
})
documented(router.delete('/files', [RagController, 'deleteFile']), {
summary: 'Delete a RAG file',
tags: ['rag'],
query: deleteFileSchema,
})
documented(router.post('/files/embed', [RagController, 'embedFile']), {
summary: 'Embed a RAG file',
tags: ['rag'],
request: embedFileSchema,
})
documented(router.get('/files/content', [RagController, 'getFileContent']), {
summary: 'Get RAG file content',
tags: ['rag'],
query: fileSourceSchema,
})
documented(router.get('/files/download', [RagController, 'downloadFile']), {
summary: 'Download a RAG file',
tags: ['rag'],
query: fileSourceSchema,
})
documented(router.get('/active-jobs', [RagController, 'getActiveJobs']), {
summary: 'List active RAG jobs',
tags: ['rag'],
})
documented(router.get('/failed-jobs', [RagController, 'getFailedJobs']), {
summary: 'List failed RAG jobs',
tags: ['rag'],
})
documented(router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs']), {
summary: 'Clean up failed RAG jobs',
tags: ['rag'],
})
documented(router.delete('/jobs', [RagController, 'cancelAllJobs']), {
summary: 'Cancel all RAG jobs',
tags: ['rag'],
})
documented(router.get('/job-status', [RagController, 'getJobStatus']), {
summary: 'Get RAG job status',
tags: ['rag'],
query: getJobStatusSchema,
})
documented(router.post('/sync', [RagController, 'scanAndSync']), {
summary: 'Scan and sync RAG files',
tags: ['rag'],
})
documented(router.post('/re-embed-all', [RagController, 'reembedAll']), {
summary: 'Re-embed all RAG files',
tags: ['rag'],
})
documented(router.post('/reset-and-rebuild', [RagController, 'resetAndRebuild']), {
summary: 'Reset and rebuild the RAG index',
tags: ['rag'],
})
documented(router.post('/estimate-batch', [RagController, 'estimateBatch']), {
summary: 'Estimate a RAG embedding batch',
tags: ['rag'],
request: estimateBatchSchema,
})
documented(router.get('/policy-prompt-state', [RagController, 'policyPromptState']), {
summary: 'Get RAG policy prompt state',
tags: ['rag'],
})
documented(router.get('/health', [RagController, 'health']), {
summary: 'RAG health check',
tags: ['rag'],
})
})
.prefix('/api/rag')
router
.group(() => {
router.get('/debug-info', [SystemController, 'getDebugInfo'])
router.get('/info', [SystemController, 'getSystemInfo'])
router.get('/internet-status', [SystemController, 'getInternetStatus'])
router.get('/services', [SystemController, 'getServices'])
router.post('/services/affect', [SystemController, 'affectService'])
router.post('/services/install', [SystemController, 'installService'])
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
router.post('/services/uninstall', [SystemController, 'uninstallService'])
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
router.get('/services/preflight', [SystemController, 'preflightCheck'])
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])
router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp'])
router.post('/services/custom', [SystemController, 'createCustomApp'])
router.put('/services/custom', [SystemController, 'updateCustomApp'])
router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest'])
router.delete('/services/custom', [SystemController, 'deleteCustomApp'])
router.get('/services/custom/:name', [SystemController, 'getCustomApp'])
router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl'])
router.get('/services/:name/logs', [SystemController, 'getServiceLogs'])
router.get('/services/:name/stats', [SystemController, 'getServiceStats'])
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])
router.post('/services/update', [SystemController, 'updateService'])
router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate'])
router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus'])
router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus'])
router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes'])
router.get('/latest-version', [SystemController, 'checkLatestVersion'])
router.post('/update', [SystemController, 'requestSystemUpdate'])
router.get('/update/status', [SystemController, 'getSystemUpdateStatus'])
router.get('/update/logs', [SystemController, 'getSystemUpdateLogs'])
router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus'])
router.get('/settings', [SettingsController, 'getSetting'])
router.patch('/settings', [SettingsController, 'updateSetting'])
documented(router.get('/debug-info', [SystemController, 'getDebugInfo']), {
summary: 'Get system debug information',
tags: ['system'],
})
documented(router.get('/info', [SystemController, 'getSystemInfo']), {
summary: 'Get system information',
tags: ['system'],
})
documented(router.get('/internet-status', [SystemController, 'getInternetStatus']), {
summary: 'Get internet connectivity status',
tags: ['system'],
})
documented(router.get('/services', [SystemController, 'getServices']), {
summary: 'List services',
tags: ['system'],
})
documented(router.post('/services/affect', [SystemController, 'affectService']), {
summary: 'Start, stop, or restart a service',
tags: ['system'],
request: affectServiceValidator,
})
documented(router.post('/services/install', [SystemController, 'installService']), {
summary: 'Install a service',
tags: ['system'],
request: installServiceValidator,
})
documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']), {
summary: 'Force reinstall a service',
tags: ['system'],
request: installServiceValidator,
})
documented(router.post('/services/uninstall', [SystemController, 'uninstallService']), {
summary: 'Uninstall a service',
tags: ['system'],
request: uninstallServiceValidator,
})
documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']), {
summary: 'Check for service updates',
tags: ['system'],
})
documented(router.get('/services/preflight', [SystemController, 'preflightCheck']), {
summary: 'Preflight a service install',
tags: ['system'],
query: preflightValidator,
})
documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']), {
summary: 'Suggest an available custom port',
tags: ['system'],
})
documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']), {
summary: 'Preflight a custom app install',
tags: ['system'],
request: preflightCustomValidator,
})
documented(router.post('/services/custom', [SystemController, 'createCustomApp']), {
summary: 'Create a custom app',
tags: ['system'],
request: customAppValidator,
})
documented(router.put('/services/custom', [SystemController, 'updateCustomApp']), {
summary: 'Update a custom app',
tags: ['system'],
request: updateCustomAppValidator,
})
documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']), {
summary: 'Pull the latest version of a custom app',
tags: ['system'],
request: installServiceValidator,
})
documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']), {
summary: 'Delete a custom app',
tags: ['system'],
request: deleteCustomAppValidator,
})
documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']), {
summary: 'Get a custom app',
tags: ['system'],
})
documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']), {
summary: 'Set a service custom URL',
tags: ['system'],
request: setServiceCustomUrlValidator,
})
documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']), {
summary: 'Get service logs',
tags: ['system'],
query: serviceLogsValidator,
})
documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']), {
summary: 'Get service stats',
tags: ['system'],
})
documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']), {
summary: 'List available service versions',
tags: ['system'],
})
documented(router.post('/services/update', [SystemController, 'updateService']), {
summary: 'Update a service',
tags: ['system'],
request: updateServiceValidator,
})
documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']), {
summary: 'Set service auto-update',
tags: ['system'],
request: setServiceAutoUpdateValidator,
})
documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']), {
summary: 'Get app auto-update status',
tags: ['system'],
})
documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']), {
summary: 'Get content auto-update status',
tags: ['system'],
})
documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']), {
summary: 'Subscribe to release notes',
tags: ['system'],
request: subscribeToReleaseNotesValidator,
})
documented(router.get('/latest-version', [SystemController, 'checkLatestVersion']), {
summary: 'Check the latest available version',
tags: ['system'],
query: checkLatestVersionValidator,
})
documented(router.post('/update', [SystemController, 'requestSystemUpdate']), {
summary: 'Request a system update',
tags: ['system'],
})
documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']), {
summary: 'Get system update status',
tags: ['system'],
})
documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']), {
summary: 'Get system update logs',
tags: ['system'],
})
documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']), {
summary: 'Get system auto-update status',
tags: ['system'],
})
documented(router.get('/settings', [SettingsController, 'getSetting']), {
summary: 'Get a system setting',
tags: ['system'],
query: getSettingSchema,
})
documented(router.patch('/settings', [SettingsController, 'updateSetting']), {
summary: 'Update a system setting',
tags: ['system'],
request: updateSettingSchema,
})
})
.prefix('/api/system')
router
.group(() => {
router.get('/list', [ZimController, 'list'])
router.get('/list-remote', [ZimController, 'listRemote'])
router.get('/curated-categories', [ZimController, 'listCuratedCategories'])
router.post('/download-remote', [ZimController, 'downloadRemote'])
router.post('/download-category-tier', [ZimController, 'downloadCategoryTier'])
documented(router.get('/list', [ZimController, 'list']), {
summary: 'List installed ZIM files',
tags: ['zim'],
})
documented(router.get('/list-remote', [ZimController, 'listRemote']), {
summary: 'List remote ZIM files',
tags: ['zim'],
query: listRemoteZimValidator,
})
documented(router.get('/curated-categories', [ZimController, 'listCuratedCategories']), {
summary: 'List curated ZIM categories',
tags: ['zim'],
})
documented(router.post('/download-remote', [ZimController, 'downloadRemote']), {
summary: 'Queue a remote ZIM download',
tags: ['zim'],
request: remoteDownloadWithMetadataValidator,
})
documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']), {
summary: 'Download a ZIM category tier',
tags: ['zim'],
request: downloadCategoryTierValidator,
})
router.post('/upload', [ZimController, 'upload'])
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
documented(router.post('/upload', [ZimController, 'upload']), {
summary: 'Upload a ZIM file',
tags: ['zim'],
})
documented(router.get('/wikipedia', [ZimController, 'getWikipediaState']), {
summary: 'Get Wikipedia ZIM state',
tags: ['zim'],
})
documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']), {
summary: 'Select a Wikipedia ZIM edition',
tags: ['zim'],
request: selectWikipediaValidator,
})
router.get('/custom-libraries', [ZimController, 'listCustomLibraries'])
router.post('/custom-libraries', [ZimController, 'addCustomLibrary'])
router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary'])
router.get('/browse-library', [ZimController, 'browseLibrary'])
documented(router.get('/custom-libraries', [ZimController, 'listCustomLibraries']), {
summary: 'List custom ZIM libraries',
tags: ['zim'],
})
documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']), {
summary: 'Add a custom ZIM library',
tags: ['zim'],
request: addCustomLibraryValidator,
})
documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']), {
summary: 'Remove a custom ZIM library',
tags: ['zim'],
params: idParamValidator,
})
documented(router.get('/browse-library', [ZimController, 'browseLibrary']), {
summary: 'Browse a ZIM library',
tags: ['zim'],
query: browseLibraryValidator,
})
router.post('/rescan-library', [ZimController, 'rescanLibrary'])
documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']), {
summary: 'Rescan the ZIM library',
tags: ['zim'],
})
router.delete('/:filename', [ZimController, 'delete'])
documented(router.delete('/:filename', [ZimController, 'delete']), {
summary: 'Delete a ZIM file',
tags: ['zim'],
params: filenameParamValidator,
})
})
.prefix('/api/zim')
router
.group(() => {
router.post('/run', [BenchmarkController, 'run'])
router.post('/run/system', [BenchmarkController, 'runSystem'])
router.post('/run/ai', [BenchmarkController, 'runAI'])
router.get('/results', [BenchmarkController, 'results'])
router.get('/results/latest', [BenchmarkController, 'latest'])
router.get('/results/:id', [BenchmarkController, 'show'])
router.post('/submit', [BenchmarkController, 'submit'])
router.post('/builder-tag', [BenchmarkController, 'updateBuilderTag'])
router.get('/comparison', [BenchmarkController, 'comparison'])
router.get('/status', [BenchmarkController, 'status'])
router.get('/settings', [BenchmarkController, 'settings'])
router.post('/settings', [BenchmarkController, 'updateSettings'])
documented(router.post('/run', [BenchmarkController, 'run']), {
summary: 'Run a benchmark',
tags: ['benchmark'],
request: runBenchmarkValidator,
})
documented(router.post('/run/system', [BenchmarkController, 'runSystem']), {
summary: 'Run a system benchmark',
tags: ['benchmark'],
})
documented(router.post('/run/ai', [BenchmarkController, 'runAI']), {
summary: 'Run an AI benchmark',
tags: ['benchmark'],
})
documented(router.get('/results', [BenchmarkController, 'results']), {
summary: 'List benchmark results',
tags: ['benchmark'],
})
documented(router.get('/results/latest', [BenchmarkController, 'latest']), {
summary: 'Get the latest benchmark result',
tags: ['benchmark'],
})
documented(router.get('/results/:id', [BenchmarkController, 'show']), {
summary: 'Get a benchmark result',
tags: ['benchmark'],
})
documented(router.post('/submit', [BenchmarkController, 'submit']), {
summary: 'Submit a benchmark result',
tags: ['benchmark'],
request: submitBenchmarkValidator,
})
documented(router.post('/builder-tag', [BenchmarkController, 'updateBuilderTag']), {
summary: 'Update the builder tag',
tags: ['benchmark'],
})
documented(router.get('/comparison', [BenchmarkController, 'comparison']), {
summary: 'Get benchmark comparison data',
tags: ['benchmark'],
})
documented(router.get('/status', [BenchmarkController, 'status']), {
summary: 'Get benchmark status',
tags: ['benchmark'],
})
documented(router.get('/settings', [BenchmarkController, 'settings']), {
summary: 'Get benchmark settings',
tags: ['benchmark'],
})
documented(router.post('/settings', [BenchmarkController, 'updateSettings']), {
summary: 'Update benchmark settings',
tags: ['benchmark'],
})
})
.prefix('/api/benchmark')

View File

@ -0,0 +1,70 @@
import { test } from '@japa/runner'
import router from '@adonisjs/core/services/router'
import { getDocRegistry } from '#start/openapi/documented'
import { buildOpenApiDocument, resetOpenApiCache } from '#start/openapi/generator'
/**
* Routes under `/api` that are intentionally NOT part of the documented API
* surface (the docs machinery itself).
*/
const UNDOCUMENTED_ALLOWLIST = new Set(['GET /api/openapi.json'])
const RELEVANT_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
/**
* Build a `${METHOD} ${pattern}` key set for a list of route JSON nodes,
* expanding multi-method routes and dropping HEAD/OPTIONS.
*/
function routeKeys(nodes: { methods: string[]; pattern: string }[]): Set<string> {
const keys = new Set<string>()
for (const node of nodes) {
for (const method of node.methods) {
if (RELEVANT_METHODS.has(method)) {
keys.add(`${method} ${node.pattern}`)
}
}
}
return keys
}
test.group('OpenAPI docs', () => {
test('generates a structurally valid OpenAPI 3.1 document', ({ assert }) => {
resetOpenApiCache()
const doc = buildOpenApiDocument()
assert.equal(doc.openapi, '3.1.0')
assert.isString(doc.info.title)
assert.isString(doc.info.version)
assert.isAbove(Object.keys(doc.paths).length, 0)
// Every operation must declare at least one response.
for (const [path, methods] of Object.entries<any>(doc.paths)) {
for (const [method, op] of Object.entries<any>(methods)) {
assert.isObject(op.responses, `${method.toUpperCase()} ${path} is missing responses`)
assert.isAbove(Object.keys(op.responses).length, 0, `${method.toUpperCase()} ${path} has empty responses`)
}
}
})
test('every /api route is documented (completeness gate)', ({ assert }) => {
// All committed routes under /api.
const apiRoutes = router
.toJSON()
.root.filter((node) => node.pattern.startsWith('/api'))
const allApiKeys = routeKeys(apiRoutes)
// Keys for the routes registered through documented().
const documentedKeys = routeKeys([...getDocRegistry().keys()].map((route) => route.toJSON()))
const missing = [...allApiKeys].filter(
(key) => !documentedKeys.has(key) && !UNDOCUMENTED_ALLOWLIST.has(key)
)
assert.deepEqual(
missing,
[],
`These /api routes are missing a documented() descriptor:\n ${missing.join('\n ')}`
)
})
})