From 9207ab368ca4914a1f4eb0a84fa6a0781971d6bb Mon Sep 17 00:00:00 2001 From: Jordan Burnett <2143374+jordan-burnett@users.noreply.github.com> Date: Fri, 10 Apr 2026 19:54:24 +0100 Subject: [PATCH] fix: support schemas defined using allOff --- src/open-api/utils/dereference.test.ts | 56 ++++++++++++++++++++++++ src/open-api/utils/dereference.ts | 10 +++++ src/open-api/utils/merge-schemas.test.ts | 43 ++++++++++++++++++ src/open-api/utils/merge-schemas.ts | 26 +++++++++++ test/oas/fixtures/response-all-of.json | 48 ++++++++++++++++++++ test/oas/oas-response.test.ts | 26 +++++++++++ 6 files changed, 209 insertions(+) create mode 100644 src/open-api/utils/merge-schemas.test.ts create mode 100644 src/open-api/utils/merge-schemas.ts create mode 100644 test/oas/fixtures/response-all-of.json diff --git a/src/open-api/utils/dereference.test.ts b/src/open-api/utils/dereference.test.ts index 434b9bb..5eb588a 100644 --- a/src/open-api/utils/dereference.test.ts +++ b/src/open-api/utils/dereference.test.ts @@ -59,3 +59,59 @@ it('dereferences', async () => { } `) }) + +it('merges schemas defined using allOf', async () => { + await expect( + dereference({ + foo: { + allOf: [ + { + $ref: '#/components/schemas/User', + }, + { + type: 'object', + properties: { + street: { type: 'string' }, + }, + }, + ], + }, + components: { + schemas: { + User: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + }), + ).resolves.toMatchInlineSnapshot(` + { + "components": { + "schemas": { + "User": { + "properties": { + "name": { + "type": "string", + }, + }, + "type": "object", + }, + }, + }, + "foo": { + "properties": { + "name": { + "type": "string", + }, + "street": { + "type": "string", + }, + }, + "type": "object", + }, + } + `) +}) diff --git a/src/open-api/utils/dereference.ts b/src/open-api/utils/dereference.ts index 904f713..f05dcd9 100644 --- a/src/open-api/utils/dereference.ts +++ b/src/open-api/utils/dereference.ts @@ -1,4 +1,5 @@ import { pointerToPath } from '@stoplight/json' +import { mergeSchemas } from './merge-schemas.js' /** * TODO: Support remote references. @@ -33,6 +34,15 @@ export async function dereference(document: unknown, root?: any): Promise { }), ) + if ('allOf' in document && Array.isArray(document['allOf'])) { + const { allOf, ...siblings } = document + const merged = allOf.reduce(mergeSchemas, {}) + for (const key of Object.keys(document)) { + Reflect.deleteProperty(document, key) + } + Object.assign(document, merged, siblings) + } + return document } diff --git a/src/open-api/utils/merge-schemas.test.ts b/src/open-api/utils/merge-schemas.test.ts new file mode 100644 index 0000000..81ec826 --- /dev/null +++ b/src/open-api/utils/merge-schemas.test.ts @@ -0,0 +1,43 @@ +import { mergeSchemas } from './merge-schemas.js' + +it('merges flat objects', () => { + expect(mergeSchemas({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }) +}) + +it('overrides primitive values with the source', () => { + expect(mergeSchemas({ a: 1 }, { a: 2 })).toEqual({ a: 2 }) +}) + +it('deeply merges nested objects', () => { + expect( + mergeSchemas( + { properties: { name: { type: 'string' } } }, + { properties: { age: { type: 'integer' } } }, + ), + ).toEqual({ + properties: { + name: { type: 'string' }, + age: { type: 'integer' }, + }, + }) +}) + +it('concatenates arrays', () => { + expect(mergeSchemas({ items: [1, 2] }, { items: [3, 4] })).toEqual({ + items: [1, 2, 3, 4], + }) +}) + +it('deduplicates primitive arrays', () => { + expect( + mergeSchemas({ required: ['id', 'name'] }, { required: ['name', 'email'] }), + ).toEqual({ required: ['id', 'name', 'email'] }) +}) + +it('returns source when target is not an object', () => { + expect(mergeSchemas('string', { a: 1 })).toEqual({ a: 1 }) +}) + +it('returns source when source is not an object', () => { + expect(mergeSchemas({ a: 1 }, 'string')).toEqual('string') +}) diff --git a/src/open-api/utils/merge-schemas.ts b/src/open-api/utils/merge-schemas.ts new file mode 100644 index 0000000..7798645 --- /dev/null +++ b/src/open-api/utils/merge-schemas.ts @@ -0,0 +1,26 @@ +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function mergeSchemas(target: unknown, source: unknown): unknown { + if (!isPlainObject(target) || !isPlainObject(source)) { + return source + } + + const result: Record = { ...target } + + for (const key of Object.keys(source)) { + const targetVal = result[key] + const sourceVal = source[key] + + if (Array.isArray(targetVal) && Array.isArray(sourceVal)) { + result[key] = [...new Set([...targetVal, ...sourceVal])] + } else if (isPlainObject(targetVal) && isPlainObject(sourceVal)) { + result[key] = mergeSchemas(targetVal, sourceVal) + } else { + result[key] = sourceVal + } + } + + return result +} diff --git a/test/oas/fixtures/response-all-of.json b/test/oas/fixtures/response-all-of.json new file mode 100644 index 0000000..b99264b --- /dev/null +++ b/test/oas/fixtures/response-all-of.json @@ -0,0 +1,48 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "allOf specification", + "version": "1.0.0" + }, + "basePath": "https://example.com", + "paths": { + "/user": { + "get": { + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/UserBase" + }, + { + "example": { + "street": "123 Main St", + "town": "Springfield", + "country": "USA" + } + } + ] + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UserBase": { + "example": { + "id": "abc-123", + "firstName": "John", + "lastName": "Maverick" + } + } + } + } +} diff --git a/test/oas/oas-response.test.ts b/test/oas/oas-response.test.ts index 3398a0b..f9ffb22 100644 --- a/test/oas/oas-response.test.ts +++ b/test/oas/oas-response.test.ts @@ -47,3 +47,29 @@ it('supports a referenced response example', async () => { }, ]) }) + +it('supports a response example using allOf', async () => { + const document = require('./fixtures/response-all-of.json') + const handlers = await fromOpenApi(document) + expect(await inspectHandlers(handlers)).toEqual([ + { + handler: { + method: 'GET', + path: 'https://example.com/user', + }, + response: { + status: 200, + statusText: 'OK', + headers: expect.arrayContaining([['content-type', 'application/json']]), + body: JSON.stringify({ + id: 'abc-123', + firstName: 'John', + lastName: 'Maverick', + street: '123 Main St', + town: 'Springfield', + country: 'USA', + }), + }, + }, + ]) +})