Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fromOpenApi): support mapping and filtering operations #57

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
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 () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add another test case for mapping the operation. It may look like this:

  1. OAS with 1 operation defined.
  2. In the map function, we modify something about the operation (e.g. its path).
  3. The rest of the test as usual (request -> assertion).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great test! 45df894
I modified response, since operation itself does not contain path and that one we are not able to modify in this mapping function

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[]>([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to add the request test for /orders too, since now nothing will fail if the GET /orders operation gets request handlers generated.

Copy link
Author

@jauniusmentimeter jauniusmentimeter Jul 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm it will fail, no? Since toEqual will actually check the whole structure of actual vs expected. So if GET /orders gets generated, the handlers will contain not one handler anymore and the expect will not pass.

I added an extra expect for that to check the handlers length anyway: e3d2391

{
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
Loading