* 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 <[email protected]>
141 lines
6.1 KiB
TypeScript
141 lines
6.1 KiB
TypeScript
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { parseAnnotations, parseMiniSchema } from '../scripts/openapi/lib';
|
|
import { buildSpec } from '../scripts/openapi/build-spec';
|
|
|
|
// Wrap a single-method annotation block in a minimal +server.ts source.
|
|
function src(method: string, block: string): string {
|
|
return `/**\n${block}\n */\nexport const ${method}: RequestHandler = async () => new Response();\n`;
|
|
}
|
|
|
|
describe('parseMiniSchema: always terminates + bracket arrays', () => {
|
|
// A mini-schema string with the [T] bracket-array form (or any unrecognized
|
|
// char) must ALWAYS advance the parser cursor. A stuck cursor spun the drift
|
|
// gate for hours in CI. These strings are the ones that hung.
|
|
test('[string] parses as an array of string', () => {
|
|
expect(parseMiniSchema('[string]')).toEqual({ kind: 'array', items: { kind: 'string' } });
|
|
});
|
|
|
|
test('nested object with a [T] property (the exact string that hung CI)', () => {
|
|
const s = parseMiniSchema('{compose:string!, config:{disabled:[string], severity:object}}');
|
|
expect(s.kind).toBe('object');
|
|
expect((s as any).properties.config.properties.disabled).toEqual({ kind: 'array', items: { kind: 'string' } });
|
|
expect((s as any).required).toContain('compose');
|
|
});
|
|
|
|
test('[{...}]! required array of objects', () => {
|
|
const s = parseMiniSchema('{findings:[{ruleId:string!}]!}');
|
|
expect((s as any).properties.findings.kind).toBe('array');
|
|
expect((s as any).properties.findings.items.kind).toBe('object');
|
|
expect((s as any).required).toContain('findings');
|
|
});
|
|
|
|
test('garbage / stray brackets terminate (never infinite-loop)', () => {
|
|
// If any of these looped, the test process would hang instead of asserting.
|
|
expect(() => parseMiniSchema('{a:[[[')).not.toThrow();
|
|
expect(() => parseMiniSchema('[[[[[[')).not.toThrow();
|
|
expect(() => parseMiniSchema('{:::,,,}')).not.toThrow();
|
|
expect(() => parseMiniSchema('@#$%^&*')).not.toThrow();
|
|
});
|
|
|
|
test('array<T> long form still works (no regression)', () => {
|
|
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', () => {
|
|
test('body-raw parses media type + description', () => {
|
|
const a = parseAnnotations(
|
|
src('POST', ' * @openapi\n * summary: Load an image\n * body-raw: application/x-tar The raw image tar')
|
|
);
|
|
expect(a.POST?.bodyRaw).toEqual({ mediaType: 'application/x-tar', description: 'The raw image tar' });
|
|
});
|
|
|
|
test('body-multipart parses field/type/array/required/description', () => {
|
|
const a = parseAnnotations(
|
|
src('POST', ' * @openapi\n * summary: Upload files\n * body-multipart: files:binary[]! One or more files')
|
|
);
|
|
expect(a.POST?.bodyMultipart).toEqual({
|
|
field: 'files',
|
|
type: 'binary',
|
|
array: true,
|
|
required: true,
|
|
description: 'One or more files'
|
|
});
|
|
});
|
|
|
|
test('a JSON body: still parses as before (no regression)', () => {
|
|
const a = parseAnnotations(src('POST', ' * @openapi\n * summary: X\n * body: {name:string!}'));
|
|
expect(a.POST?.body).toBeDefined();
|
|
expect(a.POST?.bodyRaw).toBeUndefined();
|
|
expect(a.POST?.bodyMultipart).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('buildSpec: emits requestBody for non-JSON bodies', () => {
|
|
function specFor(filePath: string, openapiPath: string, method: string, block: string) {
|
|
const content = src(method, block);
|
|
const fileContents = new Map([[filePath, content]]);
|
|
const annotationsByPath = { [openapiPath]: parseAnnotations(content) };
|
|
const pathParams = [...openapiPath.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
const routes = [{ filePath, openapiPath, pathParams, tag: 'Test', methods: [method] as any }];
|
|
return buildSpec({
|
|
routes: routes as any,
|
|
fileContents,
|
|
annotationsByPath: annotationsByPath as any,
|
|
isPublicFn: () => true,
|
|
version: 'test'
|
|
}).spec;
|
|
}
|
|
|
|
test('body-raw -> application/x-tar content, required', () => {
|
|
const spec = specFor(
|
|
'/x/+server.ts',
|
|
'/api/images/load',
|
|
'POST',
|
|
' * @openapi\n * summary: Load\n * body-raw: application/x-tar The raw image tar'
|
|
);
|
|
const rb: any = spec.paths['/api/images/load'].post.requestBody;
|
|
expect(rb.required).toBe(true);
|
|
expect(rb.content['application/x-tar']).toBeDefined();
|
|
expect(rb.content['application/x-tar'].schema.type).toBe('string');
|
|
expect(rb.content['application/x-tar'].schema.format).toBe('binary');
|
|
});
|
|
|
|
test('body-multipart -> multipart/form-data with an array file field, required', () => {
|
|
const spec = specFor(
|
|
'/y/+server.ts',
|
|
'/api/containers/{id}/files/upload',
|
|
'POST',
|
|
' * @openapi\n * summary: Upload\n * body-multipart: files:binary[]! One or more files'
|
|
);
|
|
const rb: any = spec.paths['/api/containers/{id}/files/upload'].post.requestBody;
|
|
expect(rb.required).toBe(true);
|
|
const mp = rb.content['multipart/form-data'];
|
|
expect(mp).toBeDefined();
|
|
expect(mp.schema.type).toBe('object');
|
|
expect(mp.schema.properties.files.type).toBe('array');
|
|
expect(mp.schema.properties.files.items.format).toBe('binary');
|
|
expect(mp.schema.required).toEqual(['files']);
|
|
});
|
|
});
|