diff --git a/src/open-api/from-open-api.ts b/src/open-api/from-open-api.ts index 0d1050f..f64015a 100644 --- a/src/open-api/from-open-api.ts +++ b/src/open-api/from-open-api.ts @@ -1,7 +1,7 @@ import { RequestHandler, HttpHandler, http } from 'msw' import type { OpenAPIV3, OpenAPIV2, OpenAPI } from 'openapi-types' import { parse } from 'yaml' -import { normalizeSwaggerUrl } from './utils/normalize-swagger-url.js' +import { normalizeSwaggerPath } from './utils/normalize-swagger-path.js' import { getServers } from './utils/get-servers.js' import { isAbsoluteUrl, joinPaths } from './utils/url.js' import { createResponseResolver } from './utils/open-api-utils.js' @@ -12,6 +12,21 @@ const supportedHttpMethods = Object.keys( http, ) as unknown as SupportedHttpMethods +type OpenApiDocument = + | string + | OpenAPI.Document + | OpenAPIV2.Document + | OpenAPIV3.Document + +type ExtractPaths = T extends { paths: infer P } ? keyof P : never + +export type MapOperationFunction = (args: { + path: TPath + method: SupportedHttpMethods + operation: OpenAPIV3.OperationObject + document: OpenApiDocument +}) => OpenAPIV3.OperationObject | undefined + /** * Generates request handlers from the given OpenAPI V2/V3 document. * @@ -19,8 +34,12 @@ const supportedHttpMethods = Object.keys( * import specification from './api.oas.json' * await fromOpenApi(specification) */ -export async function fromOpenApi( - document: string | OpenAPI.Document | OpenAPIV3.Document | OpenAPIV2.Document, + +export async function fromOpenApi( + document: T, + mapOperation?: MapOperationFunction< + T extends string ? string : ExtractPaths + >, ): Promise> { const parsedDocument = typeof document === 'string' ? parse(document) : document @@ -33,7 +52,7 @@ export async function fromOpenApi( const pathItems = Object.entries(specification.paths ?? {}) for (const item of pathItems) { - const [url, handlers] = item + const [path, handlers] = item as [ExtractPaths, any] const pathItem = handlers as | OpenAPIV2.PathItemObject | OpenAPIV3.PathItemObject @@ -46,7 +65,20 @@ export async function fromOpenApi( continue } - const operation = pathItem[method] as OpenAPIV3.OperationObject + const rawOperation = pathItem[method] as OpenAPIV3.OperationObject + if (!rawOperation) { + continue + } + + const operation = mapOperation + ? mapOperation({ + path, + method, + operation: rawOperation, + document: specification, + }) + : rawOperation + if (!operation) { continue } @@ -54,10 +86,10 @@ export async function fromOpenApi( const serverUrls = getServers(specification) for (const baseUrl of serverUrls) { - const path = normalizeSwaggerUrl(url) + const normalizedPath = normalizeSwaggerPath(path) const requestUrl = isAbsoluteUrl(baseUrl) - ? new URL(`${baseUrl}${path}`).href - : joinPaths(path, baseUrl) + ? new URL(`${baseUrl}${normalizedPath}`).href + : joinPaths(normalizedPath, baseUrl) if ( typeof operation.responses === 'undefined' || diff --git a/src/open-api/utils/normalize-swagger-path.test.ts b/src/open-api/utils/normalize-swagger-path.test.ts new file mode 100644 index 0000000..9b2e061 --- /dev/null +++ b/src/open-api/utils/normalize-swagger-path.test.ts @@ -0,0 +1,15 @@ +import { normalizeSwaggerPath } from './normalize-swagger-path.js' + +it('replaces swagger path parameters with colons', () => { + expect(normalizeSwaggerPath('/user/{userId}')).toEqual('/user/:userId') + expect( + normalizeSwaggerPath('https://{subdomain}.example.com/{resource}/recent'), + ).toEqual('https://:subdomain.example.com/:resource/recent') +}) + +it('returns otherwise normal URL as-is', () => { + expect(normalizeSwaggerPath('/user/abc-123')).toEqual('/user/abc-123') + expect( + normalizeSwaggerPath('https://finance.example.com/reports/recent'), + ).toEqual('https://finance.example.com/reports/recent') +}) diff --git a/src/open-api/utils/normalize-swagger-url.ts b/src/open-api/utils/normalize-swagger-path.ts similarity index 68% rename from src/open-api/utils/normalize-swagger-url.ts rename to src/open-api/utils/normalize-swagger-path.ts index b62a530..96a8227 100644 --- a/src/open-api/utils/normalize-swagger-url.ts +++ b/src/open-api/utils/normalize-swagger-path.ts @@ -1,6 +1,6 @@ -export function normalizeSwaggerUrl(url: string): string { +export function normalizeSwaggerPath(path: T) { return ( - url + path // Replace OpenAPI style parameters (/pet/{petId}) // with the common path parameters (/pet/:petId). .replace(/\{(.+?)\}/g, ':$1') diff --git a/src/open-api/utils/normalize-swagger-url.test.ts b/src/open-api/utils/normalize-swagger-url.test.ts deleted file mode 100644 index 80b6910..0000000 --- a/src/open-api/utils/normalize-swagger-url.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { normalizeSwaggerUrl } from './normalize-swagger-url.js' - -it('replaces swagger path parameters with colons', () => { - expect(normalizeSwaggerUrl('/user/{userId}')).toEqual('/user/:userId') - expect( - normalizeSwaggerUrl('https://{subdomain}.example.com/{resource}/recent'), - ).toEqual('https://:subdomain.example.com/:resource/recent') -}) - -it('returns otherwise normal URL as-is', () => { - expect(normalizeSwaggerUrl('/user/abc-123')).toEqual('/user/abc-123') - expect( - normalizeSwaggerUrl('https://finance.example.com/reports/recent'), - ).toEqual('https://finance.example.com/reports/recent') -}) diff --git a/test/oas/from-open-api.test.ts b/test/oas/from-open-api.test.ts new file mode 100644 index 0000000..d9cd0d1 --- /dev/null +++ b/test/oas/from-open-api.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom +import { fromOpenApi } from '../../src/open-api/from-open-api.js' +import { createOpenApiSpec } from '../support/create-open-api-spec.js' +import { InspectedHandler, inspectHandlers } from '../support/inspect.js' + +it('creates handlers based on provided filter', async () => { + const openApiSpec = createOpenApiSpec({ + paths: { + '/numbers': { + get: { + responses: { + 200: { + description: 'Numbers response', + content: { + 'application/json': { + example: [1, 2, 3], + }, + }, + }, + }, + }, + put: { + responses: { + 200: { + description: 'Numbers response', + content: { + 'application/json': { + example: [1, 2, 3], + }, + }, + }, + }, + }, + }, + '/orders': { + get: { + responses: { + 200: { + description: 'Orders response', + content: { + 'application/json': { + example: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }, + }, + }, + }, + }, + }, + }) + + const handlers = await fromOpenApi( + openApiSpec, + ({ path, method, operation }) => { + return path === '/numbers' && method === 'get' ? operation : undefined + }, + ) + + const inspectedHandlers = await inspectHandlers(handlers) + expect(inspectHandlers.length).toBe(1) + expect(inspectedHandlers).toEqual([ + { + handler: { + method: 'GET', + path: 'http://localhost/numbers', + }, + response: { + status: 200, + statusText: 'OK', + headers: expect.arrayContaining([['content-type', 'application/json']]), + body: JSON.stringify([1, 2, 3]), + }, + }, + ]) +}) + +it('creates handler with modified response', async () => { + const openApiSpec = createOpenApiSpec({ + paths: { + '/numbers': { + description: 'Get numbers', + get: { + responses: { + 200: { + description: 'Numbers response', + content: { + 'application/json': { + example: [1, 2, 3], + }, + }, + }, + }, + }, + put: { + responses: { + 200: { + description: 'Numbers response', + content: { + 'application/json': { + example: [1, 2, 3], + }, + }, + }, + }, + }, + }, + '/orders': { + get: { + responses: { + 200: { + description: 'Orders response', + content: { + 'application/json': { + example: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }, + }, + }, + }, + }, + }, + }) + + const handlers = await fromOpenApi( + openApiSpec, + ({ path, method, operation }) => { + return path === '/numbers' && method === 'get' + ? { + ...operation, + responses: { + 200: { + description: 'Get numbers response', + content: { 'application/json': { example: [10] } }, + }, + }, + } + : operation + }, + ) + + expect(await inspectHandlers(handlers)).toEqual([ + { + handler: { + method: 'GET', + path: 'http://localhost/numbers', + }, + response: { + status: 200, + statusText: 'OK', + headers: expect.arrayContaining([['content-type', 'application/json']]), + body: JSON.stringify([10]), + }, + }, + { + handler: { + method: 'PUT', + path: 'http://localhost/numbers', + }, + response: { + status: 200, + statusText: 'OK', + headers: expect.arrayContaining([['content-type', 'application/json']]), + body: JSON.stringify([1, 2, 3]), + }, + }, + { + handler: { + method: 'GET', + path: 'http://localhost/orders', + }, + response: { + status: 200, + statusText: 'OK', + headers: expect.arrayContaining([['content-type', 'application/json']]), + body: JSON.stringify([{ id: 1 }, { id: 2 }, { id: 3 }]), + }, + }, + ]) +}) diff --git a/test/oas/oas-json-schema.test.ts b/test/oas/oas-json-schema.test.ts index 83d818e..3d7ac92 100644 --- a/test/oas/oas-json-schema.test.ts +++ b/test/oas/oas-json-schema.test.ts @@ -3,6 +3,7 @@ import { fromOpenApi } from '../../src/open-api/from-open-api.js' import { withHandlers } from '../support/with-handlers.js' import { createOpenApiSpec } from '../support/create-open-api-spec.js' import { InspectedHandler, inspectHandlers } from '../support/inspect.js' +import { OpenAPI } from 'openapi-types' const ID_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ @@ -15,6 +16,7 @@ it('supports JSON Schema object', async () => { get: { responses: { '200': { + description: 'Cart response', content: { 'application/json': { schema: { @@ -79,10 +81,10 @@ it('normalizes path parameters', async () => { createOpenApiSpec({ paths: { '/pet/{petId}': { - get: { responses: { 200: {} } }, + get: { responses: { 200: { description: '' } } }, }, '/pet/{petId}/{foodId}': { - get: { responses: { 200: {} } }, + get: { responses: { 200: { description: '' } } }, }, }, }), @@ -114,7 +116,7 @@ it('treats operations without "responses" as not implemented (501)', async () => get: { responses: null }, }, }, - }), + } as unknown as OpenAPI.Document), ) expect(await inspectHandlers(handlers)).toEqual([ { @@ -203,6 +205,7 @@ it('respects the "Accept" request header', async () => { get: { responses: { 200: { + description: 'User response', content: { 'application/json': { example: { id: 'user-1' }, diff --git a/test/oas/oas-response-headers.test.ts b/test/oas/oas-response-headers.test.ts index 1acac38..63ed649 100644 --- a/test/oas/oas-response-headers.test.ts +++ b/test/oas/oas-response-headers.test.ts @@ -11,6 +11,7 @@ it('supports response headers', async () => { get: { responses: { 200: { + description: 'User response', headers: { 'X-Rate-Limit-Remaining': { schema: { diff --git a/test/oas/oas-servers.test.ts b/test/oas/oas-servers.test.ts index ce9f5b8..cd8df0d 100644 --- a/test/oas/oas-servers.test.ts +++ b/test/oas/oas-servers.test.ts @@ -12,6 +12,7 @@ it('supports absolute server url', async () => { get: { responses: { 200: { + description: 'Numbers response', content: { 'application/json': { example: [1, 2, 3], @@ -50,6 +51,7 @@ it('supports relative server url', async () => { post: { responses: { 200: { + description: 'Token response', content: { 'plain/text': { example: 'abc-123', @@ -124,6 +126,7 @@ it('supports multiple server urls', async () => { get: { responses: { 200: { + description: 'Numbers response', content: { 'application/json': { example: [1, 2, 3], @@ -173,6 +176,7 @@ it('supports the "basePath" url', async () => { get: { responses: { 200: { + description: 'Strings response', content: { 'application/json': { example: ['a', 'b', 'c'], diff --git a/test/support/create-open-api-spec.ts b/test/support/create-open-api-spec.ts index 2b61427..45e33f1 100644 --- a/test/support/create-open-api-spec.ts +++ b/test/support/create-open-api-spec.ts @@ -1,10 +1,10 @@ import { OpenAPI } from 'openapi-types' -export function createOpenApiSpec( - document: Partial, -): OpenAPI.Document { +export function createOpenApiSpec>( + document: T, +) { return Object.assign( - {} as OpenAPI.Document, + {}, { openapi: '3.0.0', info: { @@ -14,5 +14,5 @@ export function createOpenApiSpec( paths: {}, }, document, - ) + ) satisfies T }