feat: auto-generating OpenAPI docs with Scalar UI
This commit is contained in:
parent
6a4f02dd46
commit
617391cf05
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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(),
|
||||
})
|
||||
)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 ')}`
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue