Skip to content

Commit a2afc3a

Browse files
authored
Enhance OpenAPI 3 import: add support for allOf, oneOf, and anyOf in example request generation (#9653)
1 parent 57753e2 commit a2afc3a

File tree

2 files changed

+348
-1
lines changed

2 files changed

+348
-1
lines changed
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import type { OpenAPIV3 } from 'openapi-types';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { convert } from './openapi-3';
5+
6+
describe('openapi-3', () => {
7+
describe('schema composition with allOf, oneOf, and anyOf', () => {
8+
it('should handle schema composition with pet-related schemas', async () => {
9+
const openApiDoc: OpenAPIV3.Document = {
10+
openapi: '3.1.1',
11+
info: {
12+
title: 'Pet Store API',
13+
version: '2.0.0',
14+
contact: {
15+
email: 'info@petstore.com',
16+
},
17+
},
18+
servers: [
19+
{
20+
url: 'http://localhost/petstore/api/v1',
21+
},
22+
],
23+
paths: {
24+
'/pets': {
25+
post: {
26+
operationId: 'createPet',
27+
requestBody: {
28+
content: {
29+
'application/json': {
30+
schema: {
31+
$ref: '#/components/schemas/Pet',
32+
},
33+
example: {
34+
name: 'Fluffy',
35+
age: 3,
36+
},
37+
},
38+
},
39+
},
40+
responses: {
41+
'200': {
42+
description: 'Pet created',
43+
},
44+
},
45+
},
46+
},
47+
'/cats': {
48+
post: {
49+
operationId: 'createCat',
50+
requestBody: {
51+
content: {
52+
'application/json': {
53+
schema: {
54+
$ref: '#/components/schemas/Cat',
55+
},
56+
example: {
57+
hunts: true,
58+
age: 5,
59+
},
60+
},
61+
},
62+
},
63+
responses: {
64+
'200': {
65+
description: 'Cat created',
66+
},
67+
},
68+
},
69+
},
70+
'/adopted-pets': {
71+
post: {
72+
operationId: 'createAdoptedPet',
73+
requestBody: {
74+
content: {
75+
'application/json': {
76+
schema: {
77+
$ref: '#/components/schemas/AdoptedPet',
78+
},
79+
example: {
80+
name: 'Buddy',
81+
age: 2,
82+
adoptionDate: '2024-01-15',
83+
},
84+
},
85+
},
86+
},
87+
responses: {
88+
'200': {
89+
description: 'Adopted pet created',
90+
},
91+
},
92+
},
93+
},
94+
'/pets/update': {
95+
patch: {
96+
operationId: 'updatePet',
97+
requestBody: {
98+
content: {
99+
'application/json': {
100+
schema: {
101+
$ref: '#/components/schemas/CatOrDog',
102+
},
103+
},
104+
},
105+
},
106+
responses: {
107+
'200': {
108+
description: 'Pet updated',
109+
},
110+
},
111+
},
112+
},
113+
'/pets/any': {
114+
post: {
115+
operationId: 'createAnyPet',
116+
requestBody: {
117+
content: {
118+
'application/json': {
119+
schema: {
120+
$ref: '#/components/schemas/AnyPet',
121+
},
122+
},
123+
},
124+
},
125+
responses: {
126+
'200': {
127+
description: 'Any pet created',
128+
},
129+
},
130+
},
131+
},
132+
},
133+
components: {
134+
schemas: {
135+
Pet: {
136+
allOf: [
137+
{ $ref: '#/components/schemas/PetBase' },
138+
{ $ref: '#/components/schemas/PetDetails' },
139+
],
140+
},
141+
AdoptedPet: {
142+
allOf: [
143+
{
144+
$ref: '#/components/schemas/Pet',
145+
},
146+
{
147+
type: 'object',
148+
properties: {
149+
adoptionDate: {
150+
type: 'string',
151+
example: '2024-01-15',
152+
},
153+
},
154+
},
155+
],
156+
},
157+
PetBase: {
158+
type: 'object',
159+
properties: {
160+
name: {
161+
type: 'string',
162+
example: 'Fluffy',
163+
},
164+
},
165+
},
166+
PetDetails: {
167+
type: 'object',
168+
properties: {
169+
age: {
170+
type: 'integer',
171+
example: 3,
172+
},
173+
},
174+
},
175+
Cat: {
176+
type: 'object',
177+
properties: {
178+
hunts: {
179+
type: 'boolean',
180+
example: true,
181+
},
182+
age: {
183+
type: 'integer',
184+
example: 5,
185+
},
186+
},
187+
},
188+
Dog: {
189+
type: 'object',
190+
properties: {
191+
bark: {
192+
type: 'boolean',
193+
example: true,
194+
},
195+
breed: {
196+
type: 'string',
197+
example: 'Husky',
198+
},
199+
},
200+
},
201+
CatOrDog: {
202+
oneOf: [
203+
{ $ref: '#/components/schemas/Cat' },
204+
{ $ref: '#/components/schemas/Dog' },
205+
],
206+
},
207+
AnyPet: {
208+
anyOf: [
209+
{ $ref: '#/components/schemas/Dog' },
210+
{ $ref: '#/components/schemas/Cat' },
211+
],
212+
},
213+
},
214+
},
215+
};
216+
217+
const result = await convert(JSON.stringify(openApiDoc));
218+
expect(result).not.toBeNull();
219+
220+
// Find the /pets request (allOf with Pet = PetBase + PetDetails)
221+
const petsRequest = result?.find(item => item._type === 'request' && item.url?.includes('/pets') && !item.url?.includes('/update') && !item.url?.includes('/any'));
222+
expect(petsRequest).toBeDefined();
223+
expect(petsRequest?.method).toBe('POST');
224+
expect(petsRequest?.url).toContain('/pets');
225+
226+
// Verify the /pets request body (allOf merges PetBase and PetDetails)
227+
expect(petsRequest?.body).toBeDefined();
228+
expect(petsRequest?.body?.mimeType).toBe('application/json');
229+
expect(petsRequest?.body?.text).toBeDefined();
230+
231+
const petsBodyData = JSON.parse(petsRequest?.body?.text || '{}');
232+
expect(petsBodyData.name).toBe('Fluffy');
233+
expect(petsBodyData.age).toBe(3);
234+
235+
// Verify Content-Type header
236+
const petsContentTypeHeader = petsRequest?.headers?.find(h => h.name === 'Content-Type');
237+
expect(petsContentTypeHeader).toBeDefined();
238+
expect(petsContentTypeHeader?.value).toBe('application/json');
239+
240+
// Find the /cats request (Cat schema with hunts and age)
241+
const catsRequest = result?.find(item => item._type === 'request' && item.url?.includes('/cats'));
242+
expect(catsRequest).toBeDefined();
243+
expect(catsRequest?.method).toBe('POST');
244+
expect(catsRequest?.url).toContain('/cats');
245+
246+
// Verify the /cats request body
247+
expect(catsRequest?.body).toBeDefined();
248+
expect(catsRequest?.body?.mimeType).toBe('application/json');
249+
expect(catsRequest?.body?.text).toBeDefined();
250+
251+
const catsBodyData = JSON.parse(catsRequest?.body?.text || '{}');
252+
expect(catsBodyData.hunts).toBe(true);
253+
expect(catsBodyData.age).toBe(5);
254+
255+
// Verify Content-Type header for /cats
256+
const catsContentTypeHeader = catsRequest?.headers?.find(h => h.name === 'Content-Type');
257+
expect(catsContentTypeHeader).toBeDefined();
258+
expect(catsContentTypeHeader?.value).toBe('application/json');
259+
260+
// Find the /adopted-pets request (nested allOf: Pet + adoptionDate)
261+
const adoptedPetsRequest = result?.find(item => item._type === 'request' && item.url?.includes('/adopted-pets'));
262+
expect(adoptedPetsRequest).toBeDefined();
263+
expect(adoptedPetsRequest?.method).toBe('POST');
264+
expect(adoptedPetsRequest?.url).toContain('/adopted-pets');
265+
266+
// Verify the /adopted-pets request body with nested allOf
267+
expect(adoptedPetsRequest?.body).toBeDefined();
268+
expect(adoptedPetsRequest?.body?.mimeType).toBe('application/json');
269+
expect(adoptedPetsRequest?.body?.text).toBeDefined();
270+
271+
const adoptedPetsBodyData = JSON.parse(adoptedPetsRequest?.body?.text || '{}');
272+
expect(adoptedPetsBodyData.name).toBe('Fluffy');
273+
expect(adoptedPetsBodyData.age).toBe(3);
274+
expect(adoptedPetsBodyData.adoptionDate).toBe('2024-01-15');
275+
276+
// Verify Content-Type header for /adopted-pets
277+
const adoptedPetsContentTypeHeader = adoptedPetsRequest?.headers?.find(h => h.name === 'Content-Type');
278+
expect(adoptedPetsContentTypeHeader).toBeDefined();
279+
expect(adoptedPetsContentTypeHeader?.value).toBe('application/json');
280+
281+
// Find the /pets/update request (oneOf - uses first schema: Cat)
282+
const updatePetRequest = result?.find(item => item._type === 'request' && item.url?.includes('/pets/update'));
283+
expect(updatePetRequest).toBeDefined();
284+
expect(updatePetRequest?.method).toBe('PATCH');
285+
expect(updatePetRequest?.url).toContain('/pets/update');
286+
287+
// Verify the /pets/update request body with oneOf (should use first schema: Cat)
288+
expect(updatePetRequest?.body).toBeDefined();
289+
expect(updatePetRequest?.body?.mimeType).toBe('application/json');
290+
expect(updatePetRequest?.body?.text).toBeDefined();
291+
292+
const updatePetBodyData = JSON.parse(updatePetRequest?.body?.text || '{}');
293+
expect(updatePetBodyData.hunts).toBe(true);
294+
expect(updatePetBodyData.age).toBe(5);
295+
expect(updatePetBodyData.bark).toBeUndefined();
296+
expect(updatePetBodyData.breed).toBeUndefined();
297+
298+
// Verify Content-Type header for /pets/update
299+
const updatePetContentTypeHeader = updatePetRequest?.headers?.find(h => h.name === 'Content-Type');
300+
expect(updatePetContentTypeHeader).toBeDefined();
301+
expect(updatePetContentTypeHeader?.value).toBe('application/json');
302+
303+
// Find the /pets/any request (anyOf - uses first schema: Dog)
304+
const anyPetRequest = result?.find(item => item._type === 'request' && item.url?.includes('/pets/any'));
305+
expect(anyPetRequest).toBeDefined();
306+
expect(anyPetRequest?.method).toBe('POST');
307+
expect(anyPetRequest?.url).toContain('/pets/any');
308+
309+
// Verify the /pets/any request body with anyOf (should use first schema: Dog)
310+
expect(anyPetRequest?.body).toBeDefined();
311+
expect(anyPetRequest?.body?.mimeType).toBe('application/json');
312+
expect(anyPetRequest?.body?.text).toBeDefined();
313+
314+
const anyPetBodyData = JSON.parse(anyPetRequest?.body?.text || '{}');
315+
expect(anyPetBodyData.bark).toBe(true);
316+
expect(anyPetBodyData.breed).toBe('Husky');
317+
expect(anyPetBodyData.hunts).toBeUndefined();
318+
319+
// Verify Content-Type header for /pets/any
320+
const anyPetContentTypeHeader = anyPetRequest?.headers?.find(h => h.name === 'Content-Type');
321+
expect(anyPetContentTypeHeader).toBeDefined();
322+
expect(anyPetContentTypeHeader?.value).toBe('application/json');
323+
});
324+
});
325+
});

packages/insomnia/src/main/importers/importers/openapi-3.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,7 @@ const generateParameterExample = (schema: OpenAPIV3.SchemaObject | string) => {
592592
}
593593

594594
if (schema instanceof Object) {
595-
const { type, format, example, readOnly, default: defaultValue } = schema;
595+
const { type, format, example, readOnly, default: defaultValue, allOf, oneOf, anyOf } = schema as OpenAPIV3.SchemaObject & { allOf?: OpenAPIV3.SchemaObject[]; oneOf?: OpenAPIV3.SchemaObject[]; anyOf?: OpenAPIV3.SchemaObject[] };
596596

597597
if (readOnly) {
598598
return;
@@ -606,6 +606,28 @@ const generateParameterExample = (schema: OpenAPIV3.SchemaObject | string) => {
606606
return defaultValue;
607607
}
608608

609+
// Handle allOf by merging examples from all schemas
610+
if (allOf && Array.isArray(allOf)) {
611+
const mergedExample: Record<string, unknown> = {};
612+
for (const subSchema of allOf) {
613+
const subExample = generateParameterExample(subSchema as OpenAPIV3.SchemaObject);
614+
if (subExample && typeof subExample === 'object' && !Array.isArray(subExample)) {
615+
Object.assign(mergedExample, subExample);
616+
}
617+
}
618+
return mergedExample;
619+
}
620+
621+
// Handle oneOf by using the first schema (validates against exactly one)
622+
if (oneOf && Array.isArray(oneOf) && oneOf.length > 0) {
623+
return generateParameterExample(oneOf[0] as OpenAPIV3.SchemaObject);
624+
}
625+
626+
// Handle anyOf by using the first schema (validates against any/one or more)
627+
if (anyOf && Array.isArray(anyOf) && anyOf.length > 0) {
628+
return generateParameterExample(anyOf[0] as OpenAPIV3.SchemaObject);
629+
}
630+
609631
// @ts-expect-error -- ran out of time during TypeScript conversion to handle this particular recursion
610632
const factory = typeExamples[`${type}_${format}`] || typeExamples[type];
611633

0 commit comments

Comments
 (0)