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 <jotka@users.noreply.github.com>
This commit is contained in:
1 parent
360c458144
commit
33c0c47855
3 files changed
+32
-2
No files matched your search
+11
-1
@@ -209,7 +209,8 @@ export function discoverRoutes(rootDirs: string[], routesRoot: string): { routes
|
|||||||
// * resp-<code>-example: <json>
|
// * resp-<code>-example: <json>
|
||||||
// */
|
// */
|
||||||
//
|
//
|
||||||
// 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 {
|
export function parseMiniSchema(str: string): MiniSchema {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -244,6 +245,15 @@ export function parseMiniSchema(str: string): MiniSchema {
|
|||||||
if (i === start) i++;
|
if (i === start) i++;
|
||||||
const word = s.slice(start, i) || 'string';
|
const word = s.slice(start, i) || 'string';
|
||||||
if (word === 'integer' || word === 'number' || word === 'boolean' || word === 'string') return { kind: word };
|
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' };
|
return { kind: 'string' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ async function resolveDockerHostForConfig(envIdNum: number | undefined): Promise
|
|||||||
*
|
*
|
||||||
* @openapi
|
* @openapi
|
||||||
* summary: Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission)
|
* 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)
|
* 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)
|
* 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"}
|
* 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: {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}}
|
* 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}}
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ describe('parseMiniSchema: always terminates + bracket arrays', () => {
|
|||||||
test('array<T> long form still works (no regression)', () => {
|
test('array<T> long form still works (no regression)', () => {
|
||||||
expect(parseMiniSchema('array<string>')).toEqual({ kind: 'array', items: { kind: 'string' } });
|
expect(parseMiniSchema('array<string>')).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', () => {
|
describe('parseAnnotations: non-JSON request bodies', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user