Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions src/open-api/from-open-api.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,15 +12,34 @@ const supportedHttpMethods = Object.keys(
http,
) as unknown as SupportedHttpMethods

type OpenApiDocument =
| string
| OpenAPI.Document
| OpenAPIV2.Document
| OpenAPIV3.Document

type ExtractPaths<T> = T extends { paths: infer P } ? keyof P : never

export type MapOperationFunction<TPath extends string> = (args: {
path: TPath
method: SupportedHttpMethods
operation: OpenAPIV3.OperationObject
document: OpenApiDocument
}) => OpenAPIV3.OperationObject | undefined

/**
* Generates request handlers from the given OpenAPI V2/V3 document.
*
* @example
* 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<T extends OpenApiDocument>(
document: T,
mapOperation?: MapOperationFunction<
T extends string ? string : ExtractPaths<T>
>,
): Promise<Array<RequestHandler>> {
const parsedDocument =
typeof document === 'string' ? parse(document) : document
Expand All @@ -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<T>, any]
const pathItem = handlers as
| OpenAPIV2.PathItemObject
| OpenAPIV3.PathItemObject
Expand All @@ -46,18 +65,31 @@ 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
}

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' ||
Expand Down
15 changes: 15 additions & 0 deletions src/open-api/utils/normalize-swagger-path.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function normalizeSwaggerUrl(url: string): string {
export function normalizeSwaggerPath<T extends string>(path: T) {
return (
url
path
// Replace OpenAPI style parameters (/pet/{petId})
// with the common path parameters (/pet/:petId).
.replace(/\{(.+?)\}/g, ':$1')
Expand Down
15 changes: 0 additions & 15 deletions src/open-api/utils/normalize-swagger-url.test.ts

This file was deleted.

179 changes: 179 additions & 0 deletions test/oas/from-open-api.test.ts
Original file line number Diff line number Diff line change
@@ -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<InspectedHandler[]>([
{
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<InspectedHandler[]>([
{
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 }]),
},
},
])
})
9 changes: 6 additions & 3 deletions test/oas/oas-json-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/
Expand All @@ -15,6 +16,7 @@ it('supports JSON Schema object', async () => {
get: {
responses: {
'200': {
description: 'Cart response',
content: {
'application/json': {
schema: {
Expand Down Expand Up @@ -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: '' } } },
},
},
}),
Expand Down Expand Up @@ -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<InspectedHandler[]>([
{
Expand Down Expand Up @@ -203,6 +205,7 @@ it('respects the "Accept" request header', async () => {
get: {
responses: {
200: {
description: 'User response',
content: {
'application/json': {
example: { id: 'user-1' },
Expand Down
1 change: 1 addition & 0 deletions test/oas/oas-response-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ it('supports response headers', async () => {
get: {
responses: {
200: {
description: 'User response',
headers: {
'X-Rate-Limit-Remaining': {
schema: {
Expand Down
4 changes: 4 additions & 0 deletions test/oas/oas-servers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ it('supports absolute server url', async () => {
get: {
responses: {
200: {
description: 'Numbers response',
content: {
'application/json': {
example: [1, 2, 3],
Expand Down Expand Up @@ -50,6 +51,7 @@ it('supports relative server url', async () => {
post: {
responses: {
200: {
description: 'Token response',
content: {
'plain/text': {
example: 'abc-123',
Expand Down Expand Up @@ -124,6 +126,7 @@ it('supports multiple server urls', async () => {
get: {
responses: {
200: {
description: 'Numbers response',
content: {
'application/json': {
example: [1, 2, 3],
Expand Down Expand Up @@ -173,6 +176,7 @@ it('supports the "basePath" url', async () => {
get: {
responses: {
200: {
description: 'Strings response',
content: {
'application/json': {
example: ['a', 'b', 'c'],
Expand Down
Loading