Skip to content

Commit a1f1948

Browse files
cekstedtkettanaito
andauthored
fix(fromOpenAPI): use the closest 2xx response if 200 is not defined (#87)
Co-authored-by: Artem Zakharchenko <kettanaito@gmail.com>
1 parent f2875fb commit a1f1948

File tree

3 files changed

+210
-73
lines changed

3 files changed

+210
-73
lines changed
Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,31 @@
1-
import { getAcceptedContentTypes } from './open-api-utils.js'
1+
import { getAcceptedContentTypes, getResponseStatus } from './open-api-utils.js'
22

3-
it('returns a single content type as-is', () => {
4-
expect(
5-
getAcceptedContentTypes(new Headers([['accept', 'text/html']])),
6-
).toEqual(['text/html'])
7-
})
3+
describe(getAcceptedContentTypes, () => {
4+
it('returns a single content type as-is', () => {
5+
expect(
6+
getAcceptedContentTypes(new Headers([['accept', 'text/html']])),
7+
).toEqual(['text/html'])
8+
})
89

9-
it('ignores whitespace separating multiple content types', () => {
10-
expect(
11-
getAcceptedContentTypes(
12-
new Headers([['accept', 'text/html, application/xhtml+xml, */*']]),
13-
),
14-
).toEqual(['text/html', 'application/xhtml+xml', '*/*'])
15-
})
10+
it('ignores whitespace separating multiple content types', () => {
11+
expect(
12+
getAcceptedContentTypes(
13+
new Headers([['accept', 'text/html, application/xhtml+xml, */*']]),
14+
),
15+
).toEqual(['text/html', 'application/xhtml+xml', '*/*'])
16+
})
1617

17-
it('removes an empty content type', () => {
18-
expect(getAcceptedContentTypes(new Headers([['accept', ', ,']]))).toEqual([])
18+
it('removes an empty content type', () => {
19+
expect(getAcceptedContentTypes(new Headers([['accept', ', ,']]))).toEqual(
20+
[],
21+
)
1922

20-
expect(
21-
getAcceptedContentTypes(new Headers([['accept', 'text/html, , */*']])),
22-
).toEqual(['text/html', '*/*'])
23-
})
23+
expect(
24+
getAcceptedContentTypes(new Headers([['accept', 'text/html, , */*']])),
25+
).toEqual(['text/html', '*/*'])
26+
})
2427

25-
describe.skip('complex "accept" headers', () => {
26-
it('supports weight reordering', () => {
28+
it.skip('supports weight reordering', () => {
2729
expect(
2830
getAcceptedContentTypes(
2931
new Headers([
@@ -36,7 +38,7 @@ describe.skip('complex "accept" headers', () => {
3638
).toEqual(['text/html', 'text/x-c', 'text/x-dvi', 'text/plain'])
3739
})
3840

39-
it('supports specificity reordering', () => {
41+
it.skip('supports specificity reordering', () => {
4042
expect(
4143
getAcceptedContentTypes(
4244
new Headers([
@@ -46,3 +48,28 @@ describe.skip('complex "accept" headers', () => {
4648
).toEqual(['text/plain;format=flowed', 'text/plain', 'text/*', '*/*'])
4749
})
4850
})
51+
52+
describe(getResponseStatus, () => {
53+
it('returns 200 if 200 response is defined', () => {
54+
expect(getResponseStatus({ 200: { description: '' } })).toBe('200')
55+
})
56+
57+
it('returns the first 2xx code if 200 response is not defined', () => {
58+
expect(
59+
getResponseStatus({
60+
201: { description: '' },
61+
}),
62+
).toBe('201')
63+
64+
expect(
65+
getResponseStatus({
66+
201: { description: '' },
67+
202: { description: '' },
68+
}),
69+
).toBe('201')
70+
})
71+
72+
it('returns undefined as the fallback', () => {
73+
expect(getResponseStatus({})).toBeUndefined()
74+
})
75+
})

src/open-api/utils/open-api-utils.ts

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,77 @@
11
import type { ResponseResolver } from 'msw'
2-
import { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
2+
import { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
33
import { seedSchema } from '@yellow-ticket/seed-json-schema'
44
import { toString } from './to-string.js'
55
import { STATUS_CODES } from './status-codes.js'
66

7+
/**
8+
* @note Manually type the `responses` object to be dereferenced.
9+
*/
10+
type ResponsesObject =
11+
| {
12+
[index: string]: OpenAPIV2.ResponseObject | undefined
13+
default?: OpenAPIV2.ResponseObject
14+
}
15+
| {
16+
[code: string]: OpenAPIV3.ResponseObject
17+
}
18+
| {
19+
[code: string]: OpenAPIV3_1.ResponseObject
20+
}
21+
22+
type ResponseObject = OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject
23+
24+
/**
25+
* Create a resolver function based on the responses defined for a given operation.
26+
*/
727
export function createResponseResolver(
828
operation: OpenAPI.Operation,
929
): ResponseResolver {
1030
return ({ request }) => {
11-
const { responses } = operation
31+
const responses = operation.responses as ResponsesObject
1232

13-
// Treat operations that describe no responses as not implemented.
14-
if (responses == null) {
15-
return new Response('Not Implemented', {
16-
status: 501,
17-
statusText: 'Not Implemented',
18-
})
19-
}
33+
const explicitResponseStatus = new URL(request.url).searchParams.get(
34+
'response',
35+
)
36+
37+
const responseStatus =
38+
explicitResponseStatus || getResponseStatus(responses)
39+
40+
const responseObject = responseStatus
41+
? responses[responseStatus]
42+
: responses.default
2043

21-
if (Object.keys(responses).length === 0) {
44+
if (responseObject == null) {
2245
return new Response('Not Implemented', {
2346
status: 501,
2447
statusText: 'Not Implemented',
2548
})
2649
}
2750

28-
let responseObject: OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject
29-
30-
const url = new URL(request.url)
31-
const explicitResponseStatus = url.searchParams.get('response')
32-
33-
if (explicitResponseStatus) {
34-
const responseByStatus = responses[
35-
explicitResponseStatus
36-
] as OpenAPIV3.ResponseObject
37-
38-
if (!responseByStatus) {
39-
return new Response('Not Implemented', {
40-
status: 501,
41-
statusText: 'Not Implemented',
42-
})
43-
}
44-
45-
responseObject = responseByStatus
46-
} else {
47-
const fallbackResponse =
48-
(responses['200'] as
49-
| OpenAPIV3.ResponseObject
50-
| OpenAPIV3_1.ResponseObject) ||
51-
(responses.default as
52-
| OpenAPIV3.ResponseObject
53-
| OpenAPIV3_1.ResponseObject)
54-
55-
if (!fallbackResponse) {
56-
return new Response('Not Implemented', {
57-
status: 501,
58-
statusText: 'Not Implemented',
59-
})
60-
}
61-
62-
responseObject = fallbackResponse
63-
}
64-
65-
const status = Number(explicitResponseStatus || '200')
51+
const normalizedStatus = Number(responseStatus || '200')
6652

6753
return new Response(toBody(request, responseObject), {
68-
status,
69-
statusText: STATUS_CODES[status],
54+
status: normalizedStatus,
55+
statusText: STATUS_CODES[normalizedStatus],
7056
headers: toHeaders(request, responseObject),
7157
})
7258
}
7359
}
7460

61+
export function getResponseStatus(
62+
responses: ResponsesObject,
63+
): string | undefined {
64+
if (responses['200']) {
65+
return '200'
66+
}
67+
68+
for (const status in responses) {
69+
if (status.startsWith('2')) {
70+
return status
71+
}
72+
}
73+
}
74+
7575
/**
7676
* Get the Fetch API `Headers` from the OpenAPI response object.
7777
*/
@@ -156,7 +156,7 @@ export function toHeaders(
156156
*/
157157
export function toBody(
158158
request: Request,
159-
responseObject: OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject,
159+
responseObject: ResponseObject,
160160
): RequestInit['body'] {
161161
const { content } = responseObject
162162

test/oas/oas-json-schema.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,113 @@ it('respects the "Accept" request header', async () => {
292292
}),
293293
)
294294
})
295+
296+
it('responds with 201 to a POST request with a 201 response defined', async () => {
297+
const handlers = await fromOpenApi(
298+
createOpenApiSpec({
299+
paths: {
300+
'/resource': {
301+
post: {
302+
responses: {
303+
201: { description: 'Created' },
304+
},
305+
},
306+
},
307+
},
308+
}),
309+
)
310+
311+
const response = await withHandlers(handlers, () => {
312+
return fetch('http://localhost/resource', {
313+
method: 'POST',
314+
body: JSON.stringify({ username: 'example' }),
315+
headers: {
316+
'Content-Type': 'application/json',
317+
},
318+
})
319+
})
320+
321+
expect(response.status).toEqual(201)
322+
})
323+
324+
it('responds with 204 to a DELETE request with a 204 response defined', async () => {
325+
const handlers = await fromOpenApi(
326+
createOpenApiSpec({
327+
paths: {
328+
'/resource': {
329+
delete: {
330+
responses: {
331+
204: { description: 'No Content' },
332+
},
333+
},
334+
},
335+
},
336+
}),
337+
)
338+
339+
const response = await withHandlers(handlers, () => {
340+
return fetch('http://localhost/resource', {
341+
method: 'DELETE',
342+
})
343+
})
344+
345+
expect(response.status).toEqual(204)
346+
})
347+
348+
it('responds with 201 to a POST request even if defined out of order', async () => {
349+
const handlers = await fromOpenApi(
350+
createOpenApiSpec({
351+
paths: {
352+
'/resource': {
353+
post: {
354+
responses: {
355+
404: { description: 'Not Found' },
356+
201: { description: 'Created' },
357+
},
358+
},
359+
},
360+
},
361+
}),
362+
)
363+
364+
const response = await withHandlers(handlers, () => {
365+
return fetch('http://localhost/resource', {
366+
method: 'POST',
367+
body: JSON.stringify({ username: 'example' }),
368+
headers: {
369+
'Content-Type': 'application/json',
370+
},
371+
})
372+
})
373+
374+
expect(response.status).toEqual(201)
375+
})
376+
377+
it('responds with 201 to a POST request even if default is defined', async () => {
378+
const handlers = await fromOpenApi(
379+
createOpenApiSpec({
380+
paths: {
381+
'/resource': {
382+
post: {
383+
responses: {
384+
201: { description: 'Created' },
385+
default: { description: 'An unexpected error occurred' },
386+
},
387+
},
388+
},
389+
},
390+
}),
391+
)
392+
393+
const response = await withHandlers(handlers, () => {
394+
return fetch('http://localhost/resource', {
395+
method: 'POST',
396+
body: JSON.stringify({ username: 'example' }),
397+
headers: {
398+
'Content-Type': 'application/json',
399+
},
400+
})
401+
})
402+
403+
expect(response.status).toEqual(201)
404+
})

0 commit comments

Comments
 (0)