OpenAPI spec at /api/docs with a Scalar viewer, opt-in via FEAT_API_DOCS
This commit is contained in:
6 files changed
+300
-14
No files matched your search
+28
-12
@@ -247,20 +247,36 @@ function runCheck(): number {
|
||||
}
|
||||
if (orphanMarkers.length > 0) hardFailures++;
|
||||
|
||||
// Gate 6: spec validity (write both outputs, lint the static copy)
|
||||
// Gate 6: spec validity (write both outputs, lint the static copy) via the LOCAL
|
||||
// @redocly/cli (a devDependency). It is NOT invoked through `npx --yes`: an on-demand
|
||||
// npx fetch in a fresh CI container hung this gate for HOURS. Resolve the binary from
|
||||
// node_modules; if it is not installed, SKIP (infra), do not block the release. A lint
|
||||
// that RUNS and exits non-zero is a real spec failure and is hard.
|
||||
writeSpecOutputs(spec);
|
||||
let validatorsOk = true;
|
||||
try {
|
||||
execFileSync('npx', ['--yes', '@redocly/cli@2.43.1', 'lint', STATIC_OUT_FILE, '--format=summary'], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
} catch (err: any) {
|
||||
validatorsOk = false;
|
||||
report.push(`[Gate 6] @redocly/cli lint FAILED:\n${err.stdout?.toString().slice(-1000) ?? err.message}`);
|
||||
const redoclyBin = join(ROOT_DIR, 'node_modules', '.bin', 'redocly');
|
||||
if (!existsSync(redoclyBin)) {
|
||||
report.push(`[Gate 6] Spec validity: SKIPPED (@redocly/cli not installed - run npm i)`);
|
||||
} else {
|
||||
try {
|
||||
execFileSync(redoclyBin, ['lint', STATIC_OUT_FILE, '--format=summary'], {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'pipe',
|
||||
timeout: 120_000, // belt-and-suspenders: never hang the gate
|
||||
env: { ...process.env, REDOCLY_TELEMETRY: 'off', REDOCLY_SUPPRESS_UPDATE_NOTICE: '1' }
|
||||
});
|
||||
report.push(`[Gate 6] Spec validity (@redocly/cli lint): OK`);
|
||||
} catch (err: any) {
|
||||
// A spawn/timeout failure (ENOENT/ETIMEDOUT) is infra -> skip; a non-zero exit
|
||||
// from a lint that actually ran is a real spec problem -> hard fail.
|
||||
if (err.code === 'ETIMEDOUT' || err.code === 'ENOENT') {
|
||||
report.push(`[Gate 6] Spec validity: SKIPPED (validator did not run: ${err.code})`);
|
||||
} else {
|
||||
const out = (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? '');
|
||||
report.push(`[Gate 6] @redocly/cli lint FAILED:\n${out.slice(-1000)}`);
|
||||
hardFailures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.push(`[Gate 6] Spec validity (@redocly/cli lint): ${validatorsOk ? 'OK' : 'FAILED'}`);
|
||||
if (!validatorsOk) hardFailures++;
|
||||
|
||||
console.log(`\n=== OpenAPI --check Report ===`);
|
||||
for (const line of report) console.log(line);
|
||||
|
||||
@@ -140,6 +140,33 @@ export function buildSpec({ routes, fileContents, annotationsByPath, isPublicFn,
|
||||
required: true,
|
||||
content: { 'application/json': { schema: miniSchemaToOpenApi(annotation.body), example } }
|
||||
};
|
||||
} else if (annotation?.bodyRaw) {
|
||||
// A non-JSON raw body (e.g. application/x-tar): binary string schema.
|
||||
const schema: Record<string, unknown> = { type: 'string', format: 'binary' };
|
||||
if (annotation.bodyRaw.description) schema.description = annotation.bodyRaw.description;
|
||||
requestBody = {
|
||||
required: true,
|
||||
content: { [annotation.bodyRaw.mediaType]: { schema } }
|
||||
};
|
||||
} else if (annotation?.bodyMultipart) {
|
||||
// A multipart/form-data body with a single (possibly array) file field.
|
||||
const mp = annotation.bodyMultipart;
|
||||
const fieldSchema: Record<string, unknown> = mp.array
|
||||
? { type: 'array', items: { type: mp.type === 'binary' ? 'string' : mp.type, format: mp.type === 'binary' ? 'binary' : undefined } }
|
||||
: { type: mp.type === 'binary' ? 'string' : mp.type, format: mp.type === 'binary' ? 'binary' : undefined };
|
||||
if (mp.description) fieldSchema.description = mp.description;
|
||||
requestBody = {
|
||||
required: true,
|
||||
content: {
|
||||
'multipart/form-data': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { [mp.field]: fieldSchema },
|
||||
...(mp.required ? { required: [mp.field] } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else if (['POST', 'PUT', 'PATCH'].includes(method) && analysis.bodyFields.length > 0) {
|
||||
const properties = Object.fromEntries(analysis.bodyFields.map((f) => [f, { type: 'string' }]));
|
||||
requestBody = {
|
||||
|
||||
@@ -66,6 +66,11 @@ export interface HandlerAnnotation {
|
||||
path: Record<string, ParamAnnotation>;
|
||||
body?: MiniSchema;
|
||||
bodyExample?: unknown;
|
||||
/** A non-JSON raw request body, e.g. `body-raw: application/x-tar The image tar`. */
|
||||
bodyRaw?: { mediaType: string; description: string };
|
||||
/** A multipart/form-data body: one file field, e.g.
|
||||
* `body-multipart: files:binary[]! One or more files`. */
|
||||
bodyMultipart?: { field: string; type: string; array: boolean; required: boolean; description: string };
|
||||
responses: Record<string, ResponseAnnotation>;
|
||||
/** Raw block text, kept only for --check's orphan-block diagnostics. */
|
||||
raw: string;
|
||||
@@ -217,6 +222,14 @@ export function parseMiniSchema(str: string): MiniSchema {
|
||||
function parseType(): MiniSchema {
|
||||
skipWs();
|
||||
if (s[i] === '{') return parseObject();
|
||||
if (s[i] === '[') {
|
||||
// bracket array form: [T]
|
||||
i++;
|
||||
const items = parseType();
|
||||
skipWs();
|
||||
if (s[i] === ']') i++;
|
||||
return { kind: 'array', items };
|
||||
}
|
||||
if (s.slice(i, i + 6) === 'array<') {
|
||||
i += 6;
|
||||
const items = parseType();
|
||||
@@ -226,6 +239,9 @@ export function parseMiniSchema(str: string): MiniSchema {
|
||||
}
|
||||
const start = i;
|
||||
while (i < s.length && /[a-zA-Z0-9_]/.test(s[i])) i++;
|
||||
// Guarantee forward progress: an unrecognized char (not a word, brace, or
|
||||
// bracket) must still advance i, or the parseObject loop spins forever.
|
||||
if (i === start) i++;
|
||||
const word = s.slice(start, i) || 'string';
|
||||
if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word };
|
||||
return { kind: 'string' };
|
||||
@@ -374,6 +390,27 @@ export function parseAnnotations(content: string): Partial<Record<HttpMethod, Ha
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (key === 'body-raw') {
|
||||
// `body-raw: <media-type> <description>` - a non-JSON raw body.
|
||||
const rm = rawValue.match(/^(\S+)\s*(.*)$/);
|
||||
if (rm) annotation.bodyRaw = { mediaType: rm[1], description: rm[2].trim() };
|
||||
continue;
|
||||
}
|
||||
if (key === 'body-multipart') {
|
||||
// `body-multipart: <field>:<type>[]? [!] <description>` - a multipart body.
|
||||
const mm = rawValue.match(/^([a-zA-Z0-9_]+)\s*:\s*([a-zA-Z0-9_]+)(\[\])?(!)?\s*(.*)$/);
|
||||
if (mm) {
|
||||
const [, field, type, array, required, description] = mm;
|
||||
annotation.bodyMultipart = {
|
||||
field,
|
||||
type,
|
||||
array: !!array,
|
||||
required: !!required,
|
||||
description: description.trim()
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const respMatch = key.match(/^resp-(\d{3})(-desc|-example)?$/);
|
||||
if (respMatch) {
|
||||
const [, code, suffix] = respMatch;
|
||||
|
||||
@@ -7329,7 +7329,30 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`."
|
||||
"description": "Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
},
|
||||
"description": "One or more files to write into the target directory"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"files"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/containers/{id}/inspect": {
|
||||
@@ -15491,7 +15514,19 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected."
|
||||
"description": "The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/x-tar": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"description": "The raw image tar, streamed to the daemon (docker load)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/images/pull": {
|
||||
@@ -24994,6 +25029,175 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/stacks/{name}/validate": {
|
||||
"post": {
|
||||
"operationId": "post_api_stacks_name_validate",
|
||||
"tags": [
|
||||
"stacks"
|
||||
],
|
||||
"summary": "Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission)",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Stack name (used to exclude the stack's own containers from cross-stack collision checks)"
|
||||
},
|
||||
{
|
||||
"name": "env",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"description": "Environment ID for the context-aware checks (from GET /api/environments)"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ruleId": {
|
||||
"type": "string"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"hint": {
|
||||
"type": "string"
|
||||
},
|
||||
"service": {
|
||||
"type": "string"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer"
|
||||
},
|
||||
"fix": {
|
||||
"type": "string"
|
||||
},
|
||||
"fixDescription": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ruleId",
|
||||
"severity",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
"counts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "integer"
|
||||
},
|
||||
"warn": {
|
||||
"type": "integer"
|
||||
},
|
||||
"info": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"warn",
|
||||
"info"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"findings",
|
||||
"counts"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"findings": [
|
||||
{
|
||||
"ruleId": "LATEST_TAG",
|
||||
"severity": "warn",
|
||||
"message": "\"web\" uses `:latest` (nginx:latest)",
|
||||
"hint": "Pin a version tag for reproducible deploys and to enable newer-version detection.",
|
||||
"service": "web",
|
||||
"line": 3
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"error": 0,
|
||||
"warn": 1,
|
||||
"info": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "compose content is required"
|
||||
},
|
||||
"403": {
|
||||
"description": "Permission denied"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compose": {
|
||||
"type": "string"
|
||||
},
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"disabled": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"severity": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"compose"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"compose": "services:\n web:\n image: nginx:latest\n ports: [\"8080:80\"]\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/stacks/adopt": {
|
||||
"post": {
|
||||
"operationId": "post_api_stacks_adopt",
|
||||
|
||||
@@ -90,6 +90,7 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
|
||||
* @openapi
|
||||
* summary: Upload one or more files into a container directory (requires the 'exec' permission)
|
||||
* description: Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`.
|
||||
* body-multipart: files:binary[]! One or more files to write into the target directory
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: path:string! Absolute target directory inside the container
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RequestHandler } from './$types';
|
||||
* @openapi
|
||||
* summary: Load a Docker image from an uploaded tar (docker load) for air-gapped hosts
|
||||
* description: The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected.
|
||||
* body-raw: application/x-tar The raw image tar, streamed to the daemon (docker load)
|
||||
* query: env:integer Target environment id
|
||||
* resp-200: {success:boolean!, loaded:string}
|
||||
* resp-200-desc: loaded echoes the daemon's final line, e.g. "Loaded image: alpine:3.20"
|
||||
|
||||
Reference in new issue
Block a user