diff --git a/src/manifest.ts b/src/manifest.ts index e05565e..3fb994d 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -160,7 +160,8 @@ const JSON_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', ' * compositions from the loaded parameter schema when Forge narrows them. */ function primitiveComposition(schema: Record | undefined): Record | undefined { if (!schema) return undefined; - const keys = ['anyOf', 'oneOf'].filter(key => Array.isArray(schema[key])); + if (!Array.isArray(schema.anyOf) && !Array.isArray(schema.oneOf)) return undefined; + const keys = ['allOf', 'anyOf', 'oneOf'].filter(key => Array.isArray(schema[key])); if (keys.length === 0) return undefined; const compositions: Record = {}; for (const key of keys) { @@ -169,17 +170,22 @@ function primitiveComposition(schema: Record | undefined): Reco const mapped = branches.map((branch) => { const node = resolveDocRef(branch) as Record | undefined; if (!node || typeof node.type !== 'string' || !['string', 'number', 'integer', 'boolean', 'null'].includes(node.type)) return undefined; - const { $ref: _ref, nullable: _nullable, xml: _xml, ...constraints } = node; + const { $ref: _ref, nullable: _nullable, xml: _xml, format: _format, ...constraints } = node; + if (key !== 'allOf' && _format !== undefined) constraints.format = _format; return constraints; }); if (mapped.some((branch) => branch === undefined)) return undefined; compositions[key] = mapped; } - return { + const result = { ...(typeof schema.type === 'string' ? { type: schema.type } : {}), ...(Array.isArray(schema.enum) ? { enum: schema.enum } : {}), ...compositions, }; + return { + ...(schema.nullable === true ? { anyOf: [result, { type: 'null' }] } : result), + ...(schema.default !== undefined ? { default: schema.default } : {}), + }; } /** diff --git a/test/parameter-composition-root.test.ts b/test/parameter-composition-root.test.ts index d8328c9..6b47203 100644 --- a/test/parameter-composition-root.test.ts +++ b/test/parameter-composition-root.test.ts @@ -42,3 +42,61 @@ for (const location of ['path', 'query', 'header', 'cookie']) { assert.equal(validate({ v: 'safe' }), false); }); } + +for (const location of ['path', 'query', 'header', 'cookie']) { + test(`primitive ${location} parameters retain allOf constraints alongside anyOf`, async () => { + const doc = { openapi: '3.1.0', info: { title: 'AllOf intersection', version: '1' }, components: { schemas: {} }, + paths: { [location === 'path' ? '/x/{v}' : '/x']: { get: { operationId: 'x', parameters: [{ name: 'v', in: location, required: true, schema: { + allOf: [{ type: 'integer', minimum: 5 }], anyOf: [{ type: 'integer' }, { type: 'string' }], + } }], responses: { '200': { description: 'OK' } } } } }, + } as unknown as OpenAPIV3.Document; + await init(doc); + const tool = buildManifest(doc).tools[0]!; + const validate = compileOutputValidator(JSON.parse(JSON.stringify(tool.inputSchema))); + assert.equal(validate({ v: 5 }), true); + assert.equal(validate({ v: 4 }), false); + assert.equal(validate({ v: 'safe' }), false); + }); +} + +for (const [label, source, expected] of [ + ['allOf-only enum default', { allOf: [{ $ref: '#/components/schemas/Enum' }], default: 'safe' }, { type: 'string', enum: ['safe'], default: 'safe' }], + ['allOf-only nullable', { nullable: true, allOf: [{ type: 'string' }] }, { type: ['string', 'null'] }], +] as const) { + test(`query parameter preserves the existing flat view for ${label}`, async () => { + const doc = { openapi: '3.0.3', info: { title: 'Old allOf', version: '1' }, components: { schemas: { Enum: { type: 'string', enum: ['safe'] } } }, + paths: { '/x': { get: { operationId: 'x', parameters: [{ name: 'v', in: 'query', schema: source }], responses: { '200': { description: 'OK' } } } } }, + } as unknown as OpenAPIV3.Document; + await init(doc); + assert.deepEqual(buildManifest(doc).tools[0]!.args[0]!.schema, expected); + }); +} + +test('composed query parameter retains default and nullable without branch format', async () => { + const doc = { openapi: '3.0.3', info: { title: 'Composed nullable', version: '1' }, components: { schemas: {} }, paths: { + '/x': { get: { operationId: 'x', parameters: [{ name: 'v', in: 'query', schema: { + nullable: true, default: 'safe', allOf: [{ type: 'string', format: 'custom' }], anyOf: [{ type: 'string', pattern: '^safe$' }], + } }], responses: { '200': { description: 'OK' } } } }, + } } as unknown as OpenAPIV3.Document; + await init(doc); + const tool = buildManifest(doc).tools[0]!; + assert.equal(tool.args[0]!.schema.default, 'safe'); + assert.ok(!JSON.stringify(tool.args[0]!.schema).includes('format')); + const validate = compileOutputValidator(JSON.parse(JSON.stringify(tool.inputSchema))); + assert.equal(validate({ v: null }), true); + assert.equal(validate({ v: 'safe' }), true); + assert.equal(validate({ v: 'unsafe' }), false); +}); + +for (const keyword of ['anyOf', 'oneOf']) { + test(`${keyword}-only primitive branches retain their existing format annotation`, async () => { + const doc = { openapi: '3.1.0', info: { title: 'Branch formats', version: '1' }, components: { schemas: {} }, paths: { + '/x': { get: { operationId: 'x', parameters: [{ name: 'v', in: 'query', schema: { + [keyword]: [{ type: 'string', format: 'uuid' }, { type: 'integer' }], + } }], responses: { '200': { description: 'OK' } } } }, + } } as unknown as OpenAPIV3.Document; + await init(doc); + const schema = buildManifest(doc).tools[0]!.args[0]!.schema; + assert.equal((schema[keyword] as Record[])[0]!.format, 'uuid'); + }); +}