From 33c0c478555e5b3e4eccc4e8bdd797067e7e3c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Strausmann?= Date: Sun, 6 Sep 2026 12:21:12 +0200 Subject: [PATCH] fix(openapi): recognize bare `object` type + document `existing` field on validate (#1532) * fix(openapi): recognize bare `object` type in annotation parser The @openapi annotation parser's parseMiniSchema() only recognized the bare words integer/number/boolean/string as scalar types. Any other bare word - including `object`, used in 16 route annotations across the codebase (e.g. severity:object, envVars:object on POST /api/stacks/{name}/validate) - silently fell through to the generic string fallback, so these fields were documented in static/openapi.json as {"type": "string"} instead of {"type": "object"}. Add a dedicated branch for bare `object`, producing an opaque object schema (no declared properties, since the bare word carries no shape information of its own - a documented shape should use the existing {...} form). Verified no other bare non-standard word is used in any body:/resp-*: annotation line across the 257 route files, so this closes the gap completely for the current codebase. Fixes #1531 * docs(openapi): document the existing field on POST /api/stacks/{name}/validate The handler reads body.existing (an optional boolean, undocumented) to control self-exclusion of the stack's own running containers/ports from the cross-stack collision checks - the exact case the endpoint's own summary advertises as handled. Without it, a client built strictly from the OpenAPI document has no way to learn the field exists, and will validate an existing stack the same way as a new one, silently reintroducing false-positive collision findings against the stack's own containers. Add existing:boolean to the body: annotation, plus a one-line description explaining the true/omitted semantics. Fixes #1530 --------- Co-authored-by: Jarek Krochmalski --- scripts/openapi/lib.ts | 12 +++++++++++- .../api/stacks/[name]/validate/+server.ts | 3 ++- tests/openapi-body-annotations.test.ts | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/scripts/openapi/lib.ts b/scripts/openapi/lib.ts index 22b3f86..70294d8 100644 --- a/scripts/openapi/lib.ts +++ b/scripts/openapi/lib.ts @@ -209,7 +209,8 @@ export function discoverRoutes(rootDirs: string[], routesRoot: string): { routes // * resp--example: // */ // -// mini-schema := 'string'|'integer'|'number'|'boolean' | '{' (name':'type'!'?','?)* '}' | 'array<' type '>' +// mini-schema := 'string'|'integer'|'number'|'boolean'|'object' | '{' (name':'type'!'?','?)* '}' | 'array<' type '>' +// (bare 'object' = an opaque object with no declared properties; use '{...}' for a documented shape) export function parseMiniSchema(str: string): MiniSchema { let i = 0; @@ -244,6 +245,15 @@ export function parseMiniSchema(str: string): MiniSchema { if (i === start) i++; const word = s.slice(start, i) || 'string'; if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word }; + // Bare `object` (no `{...}` shape given) — an opaque/untyped object, used + // throughout the codebase for fields whose internal shape isn't part of + // the documented contract (e.g. `severity:object`, `envVars:object`, + // `config:object`). Without this branch it silently fell through to the + // generic `string` fallback below, so every one of these fields was + // documented in static/openapi.json as `{ "type": "string" }` instead of + // `{ "type": "object" }` — wrong for any client generated strictly from + // the spec (a typed SDK, an MCP server mirroring the schema, ...). + if (word === 'object') return { kind: 'object', properties: {}, required: [] }; return { kind: 'string' }; } diff --git a/src/routes/api/stacks/[name]/validate/+server.ts b/src/routes/api/stacks/[name]/validate/+server.ts index ff6b0e6..a9e6e28 100644 --- a/src/routes/api/stacks/[name]/validate/+server.ts +++ b/src/routes/api/stacks/[name]/validate/+server.ts @@ -35,9 +35,10 @@ async function resolveDockerHostForConfig(envIdNum: number | undefined): Promise * * @openapi * summary: Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission) + * description: Set existing:true when validating an already-deployed stack, so the stack's own running containers/ports are excluded from the cross-stack collision checks; omit it (or false) for a brand-new stack, where a name/port clash with a running stack of the same name must still be reported. * path: name:string! Stack name (used to exclude the stack's own containers from cross-stack collision checks) * query: env:integer Environment ID for the context-aware checks (from GET /api/environments) - * body: {compose:string!, config:{disabled:[string], severity:object}, envVars:object} + * body: {compose:string!, existing:boolean, config:{disabled:[string], severity:object}, envVars:object} * body-example: {"compose":"services:\n web:\n image: nginx:latest\n ports: [\"8080:80\"]\n"} * resp-200: {findings:[{ruleId:string!, severity:string!, message:string!, hint:string, service:string, line:integer, fix:object, fixDescription:string}]!, counts:{error:integer!, warn:integer!, info:integer!}!} * resp-200-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}} diff --git a/tests/openapi-body-annotations.test.ts b/tests/openapi-body-annotations.test.ts index aecb14e..b902bd7 100644 --- a/tests/openapi-body-annotations.test.ts +++ b/tests/openapi-body-annotations.test.ts @@ -41,6 +41,25 @@ describe('parseMiniSchema: always terminates + bracket arrays', () => { test('array long form still works (no regression)', () => { expect(parseMiniSchema('array')).toEqual({ kind: 'array', items: { kind: 'string' } }); }); + + test('bare `object` parses as an opaque object, not the string fallback', () => { + expect(parseMiniSchema('object')).toEqual({ kind: 'object', properties: {}, required: [] }); + }); + + test('nested object with a bare `object` property (the real body: annotation on POST /api/stacks/{name}/validate — was silently "string")', () => { + // Before this fix, `severity` and `envVars` both fell through to + // `{ kind: 'string' }` because `parseType()` only recognized + // integer/number/boolean/string as bare words. `existing` (added for + // #1530) is a plain `boolean` and was never affected by this bug — + // included here only to keep this literal in sync with the real + // annotation. + const s = parseMiniSchema( + '{compose:string!, existing:boolean, config:{disabled:[string], severity:object}, envVars:object}' + ); + expect((s as any).properties.config.properties.severity).toEqual({ kind: 'object', properties: {}, required: [] }); + expect((s as any).properties.envVars).toEqual({ kind: 'object', properties: {}, required: [] }); + expect((s as any).properties.existing).toEqual({ kind: 'boolean' }); + }); }); describe('parseAnnotations: non-JSON request bodies', () => {