diff --git a/packages/plugin-openapi/src/openapi.ts b/packages/plugin-openapi/src/openapi.ts index 9f727471..18eef3ea 100644 --- a/packages/plugin-openapi/src/openapi.ts +++ b/packages/plugin-openapi/src/openapi.ts @@ -9,8 +9,12 @@ import { MOCKS_SERVER_ROUTE_ID, MOCKS_SERVER_VARIANT_ID, VariantTypes, CONTENT_T const methods = Object.values(OpenAPIV3Object.HttpMethods); +function isEmpty(value: TValue | null | undefined): value is null | undefined { + return value == null; +} + function notEmpty(value: TValue | null | undefined): value is TValue { - return value !== null && value !== undefined; + return !isEmpty(value); } function replaceTemplateInPath(path: string): string { @@ -82,7 +86,7 @@ function getStatusCode(code: string, codes: string[]): number { } function openApiResponseExampleToVariant(exampleId: string, code: number, variantType: RouteVariantTypes, mediaType: string, openApiResponseExample: OpenAPIV3.ExampleObject, openApiResponseHeaders?: OpenAPIV3.ResponseHeaders): RouteVariant | null { - if(!notEmpty(openApiResponseExample) || !notEmpty(openApiResponseExample.value)) { + if(isEmpty(openApiResponseExample) || isEmpty(openApiResponseExample.value)) { return null; } @@ -122,12 +126,185 @@ function openApiResponseExamplesToVariants(code: number, variantType: RouteVaria }).filter(notEmpty); } - const res = openApiResponseExampleToVariant("example", code, variantType, mediaType, { value: example }, openApiResponseHeaders); - return res ? [res] : null; + let res = openApiResponseExampleToVariant("example", code, variantType, mediaType, { value: example }, openApiResponseHeaders); + + if (res) return [res]; + + const { schema } = openApiResponseMediaType; + + if (isSchemaObject(schema) ) { + if (notEmpty(schema.example)) { + return openApiResponseExamplesToVariants( + code, + variantType, + mediaType, + schema, + openApiResponseHeaders, + ); + } + + res = openApiResponseExampleToVariant( + "example", + code, + variantType, + mediaType, + { value: collectExampleFromSchema(schema) }, + openApiResponseHeaders, + ); + + if (res) { + return [res]; + } + } + + return null; +} + +function isSchemaObject(value: unknown): value is OpenAPIV3.DereferencedSchemaObject { + return value != null && typeof value === 'object' && !Array.isArray(value) && !('$ref' in value); +} + +function collectExampleFromSchema(schema: OpenAPIV3.DereferencedSchemaObject, isArray = false): Record[] | Record | null { + if (schema.type === "object") { + // @ts-expect-error somehow TS is not able to narrow the discriminated + // union type and thinks that `schema.type` could still be "array" + return collectExampleFromObjectSchema(schema); + } + + if (schema.type === 'array') { + // @ts-expect-error same problem as above, type narrowing is not working + // here. + const items = schema.items; + + if (!items) return null; + + return collectExampleFromSchema(items, true); + } + + if (schema.type === 'boolean' || schema.type === 'integer' || schema.type === 'number' || schema.type === 'string') { + const returnValue = schema.example ?? schema.enum?.[0] ?? getExampleFromType(schema); + + return isArray ? [returnValue] : returnValue; + } + + if (schema.allOf) { + if (!validateAllOfSchema(schema)) { + return null; + } + + const mergedProps = schema.allOf.reduce<{[name: string]: OpenAPIV3.DereferencedSchemaObject}>( + (acc, cur) => ({ + ...acc, + ...(cur.properties), + }), + {}, + ); + + const result = collectExampleFromObjectSchema({ properties: mergedProps }); + + return isArray && result ? [result] : result; + } + + if (schema.oneOf) { + return collectExampleFromSchema(schema.oneOf[0]); + } + + if (schema.anyOf) { + return collectExampleFromSchema(schema.anyOf[0]); + } + + return null; +} + +function validateAllOfSchema(schema: OpenAPIV3.DereferencedSchemaObject): schema is OpenAPIV3.NonArrayDereferencedSchemaObject { + const valid = schema.allOf?.every(entry => isSchemaObject(entry) && entry.type === 'object' && Boolean(entry.properties)); + + return Boolean(valid); +} + + +function collectExampleFromObjectSchema(schema: OpenAPIV3.NonArrayDereferencedSchemaObject) { + const propertyEntries = Object.entries(schema.properties ?? {}).map(([key, prop]) => [key, prop.example ?? prop.enum?.[0] ?? getExampleFromType(prop)]).filter(([, value]) => notEmpty(value)); + + const properties = propertyEntries.length ? Object.fromEntries(propertyEntries) as Record : null; + const additionalProperties = collectAdditionalPropertiesExample(schema); + + if (properties == null && additionalProperties == null) return null; + + const props = properties || {}; + const add = additionalProperties || {}; + + return { + ...props, + ...add, + } +} + +function collectAdditionalPropertiesExample(schema: OpenAPIV3.NonArrayDereferencedSchemaObject) { + if (!schema.additionalProperties) return null; + + if (typeof schema.additionalProperties === 'boolean') { + return { additionalProp1: {} }; + } + + const exampleValue = schema.additionalProperties.example || getExampleFromType(schema.additionalProperties); + + if (!exampleValue) return null; + + return { + additionalProp1: exampleValue, + additionalProp2: exampleValue, + additionalProp3: exampleValue, + } +} + +function getExampleFromType(schema: OpenAPIV3.DereferencedSchemaObject): Array | string | number | boolean | object | null { + const defaultValues = new Map([ + ['number', 0,], + ['integer', 0,], + ['boolean', true,], + ['object', {},], + ['string', 'string',], + ['string.date', new Date().toISOString().split('T')[0]], + ['string.date-time', new Date().toISOString()], + ['string.email', 'user@example.com',], + ['string.uuid', '3fa85f64-5717-4562-b3fc-2c963f66afa6',], + ['string.hostname', 'example.com',], + ['string.ipv4', '198.51.100.42',], + ['string.ipv6', '2001:0db8:5b96:0000:0000:426f:8e17:642a',], + ]); + + if (!schema.type) { + return null; + } + + let key = schema.type; + + if (key === 'array') { + // @ts-expect-error type narrowing not working for some reason (schema + // should be narrowed to "ArraySchemaObject") + if (schema.items == null) return null; + // @ts-expect-error same problem as above + const itemType = getExampleFromType(schema.items); + + return itemType ? [itemType] : null; + } + + if (schema.format) { + key += `.${schema.format}`; + + if (!defaultValues.has(key)) { + key = schema.type + } + } + + // @ts-expect-error The check with Map.has should exclude "undefined" from + // the Map.get return type union, but TS doesn't support that yet + return defaultValues.has(key) ? defaultValues.get(key) : null; } function openApiResponseMediaToVariants(code: number, mediaType: string, openApiResponseMediaType?: OpenAPIV3.MediaTypeObject, openApiResponseHeaders?: OpenAPIV3.ResponseHeaders): RouteVariants { - if(!notEmpty(openApiResponseMediaType)) { + if(isEmpty(openApiResponseMediaType)) { return null; } if(isJsonMediaType(mediaType)) { @@ -140,7 +317,7 @@ function openApiResponseMediaToVariants(code: number, mediaType: string, openApi } function openApiResponseCodeToVariants(code: number, openApiResponse?: OpenAPIV3.ResponseObject): RouteVariants { - if(!notEmpty(openApiResponse)) { + if(isEmpty(openApiResponse)) { return []; } const content = openApiResponse.content; @@ -153,7 +330,7 @@ function openApiResponseCodeToVariants(code: number, openApiResponse?: OpenAPIV3 } function routeVariants(openApiResponses?: OpenAPIV3.ResponsesObject): RouteVariants { - if(!notEmpty(openApiResponses)) { + if(isEmpty(openApiResponses)) { return []; } const codes = Object.keys(openApiResponses); @@ -169,7 +346,7 @@ function getCustomRouteId(openApiOperation: OpenAPIV3.OperationObject): string | } function openApiPathToRoutes(path: string, basePath = "", openApiPathObject?: OpenAPIV3.PathItemObject ): Routes | null { - if(!notEmpty(openApiPathObject)) { + if(isEmpty(openApiPathObject)) { return null; } return methods.map(method => { diff --git a/packages/plugin-openapi/src/types.ts b/packages/plugin-openapi/src/types.ts index 2b051901..2eaee7dd 100644 --- a/packages/plugin-openapi/src/types.ts +++ b/packages/plugin-openapi/src/types.ts @@ -4,6 +4,10 @@ import { MOCKS_SERVER_ROUTE_ID, MOCKS_SERVER_VARIANT_ID } from "./constants"; import type { OpenAPIV3 as OriginalOpenApiV3 } from "openapi-types"; +type Prettify = { + [K in keyof T]: T[K]; +} & object; + // eslint-disable-next-line @typescript-eslint/no-namespace export namespace OpenAPIV3 { export interface ResponseObject extends OriginalOpenApiV3.ResponseObject { [MOCKS_SERVER_VARIANT_ID]?: string } @@ -16,6 +20,26 @@ export namespace OpenAPIV3 { export type ResponseHeaders = ResponseObject["headers"] + + export type DereferencedSchemaObject = Prettify & { + additionalProperties?: boolean | DereferencedSchemaObject; + properties?: { + [name: string]: DereferencedSchemaObject; + }; + allOf?: ( DereferencedSchemaObject)[]; + oneOf?: ( DereferencedSchemaObject)[]; + anyOf?: ( DereferencedSchemaObject)[]; + not?: DereferencedSchemaObject; + }> + + export type ArraySchemaObject = { type: 'array', items: DereferencedSchemaObject }; + + export type NonArraySchemaObject = OriginalOpenApiV3.NonArraySchemaObject; + + export type NonArrayDereferencedSchemaObject = Prettify & { + type?: OriginalOpenApiV3.NonArraySchemaObjectType; + }> + export interface OperationObject extends OriginalOpenApiV3.OperationObject { [MOCKS_SERVER_ROUTE_ID]?: string } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/plugin-openapi/test/example-in-schema.spec.js b/packages/plugin-openapi/test/example-in-schema.spec.js new file mode 100644 index 00000000..c2f295d3 --- /dev/null +++ b/packages/plugin-openapi/test/example-in-schema.spec.js @@ -0,0 +1,347 @@ +import { startServer, waitForServer } from "./support/helpers"; + +describe("when openapi has object with examples", () => { + let server; + + beforeAll(async () => { + server = await startServer("example-in-schema"); + await waitForServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe("routes", () => { + it("should have created routes from openapi", async () => { + expect(server.mock.routes.plain).toEqual([ + { + id: "infoVersion", + url: "/api/info/version", + method: "get", + delay: null, + variants: ["infoVersion:200-json-example", "infoVersion:400-json-example"], + }, + { + id: "serverVersion", + url: "/api/server/version", + method: "get", + delay: null, + variants: [ + "serverVersion:200-json-example", + "serverVersion:400-json-example", + "serverVersion:408-json-example", + "serverVersion:409-json-example", + "serverVersion:410-json-example", + "serverVersion:411-json-example", + "serverVersion:412-json-example", + "serverVersion:416-json-example", + "serverVersion:417-json-example", + "serverVersion:423-text-example", + "serverVersion:424-json-example", + "serverVersion:500-text-example", + ], + }, + { + id: "getUsers", + url: "/api/users", + method: "get", + delay: null, + variants: [ + "getUsers:200-json-example", + "getUsers:418-text-example", + "getUsers:500-text-example", + ], + }, + { + id: "getUser", + url: "/api/users/:id", + method: "get", + delay: null, + variants: [ + "getUser:200-json-example", + "getUser:404-json-example", + "getUser:406-json-example", + ], + }, + ]); + }); + + it("Should have created variants from openapi with examples defined in objects", async () => { + expect(server.mock.routes.plainVariants).toEqual([ + { + id: "infoVersion:200-json-example", + type: "json", + route: "infoVersion", + delay: null, + disabled: false, + preview: { + status: 200, + body: { + success: true, + }, + }, + }, + { + id: "infoVersion:400-json-example", + type: "json", + route: "infoVersion", + delay: null, + disabled: false, + preview: { + status: 400, + body: { + success: false, + }, + }, + }, + { + id: "serverVersion:200-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 200, + body: { + success: true, + }, + }, + }, + { + id: "serverVersion:400-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 400, + body: { + success: false, + }, + }, + }, + { + id: "serverVersion:408-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 408, + body: { + prop1: "string", + prop2: "string", + prop3: "string", + }, + }, + }, + { + id: "serverVersion:409-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 409, + body: { + additionalProp1: "string", + additionalProp2: "string", + additionalProp3: "string", + }, + }, + }, + { + id: "serverVersion:410-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 410, + body: { + additionalProp1: "Hello", + additionalProp2: "Hello", + additionalProp3: "Hello", + }, + }, + }, + { + id: "serverVersion:411-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 411, + body: { + en: "Hello", + fr: "Bonjour", + }, + }, + }, + { + id: "serverVersion:412-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 412, + body: { + additionalProp1: {}, + }, + }, + }, + { + id: "serverVersion:416-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 416, + body: { + additionalProp1: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], + additionalProp2: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], + additionalProp3: ["3fa85f64-5717-4562-b3fc-2c963f66afa6"], + }, + }, + }, + { + id: "serverVersion:417-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 417, + body: { + additionalProp1: "string", + additionalProp2: "string", + additionalProp3: "string", + }, + }, + }, + { + id: "serverVersion:423-text-example", + type: "text", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 423, + body: "example.com", + }, + }, + { + id: "serverVersion:424-json-example", + type: "json", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 424, + body: { + message: "string", + }, + }, + }, + { + id: "serverVersion:500-text-example", + type: "text", + route: "serverVersion", + delay: null, + disabled: false, + preview: { + status: 500, + body: "Internal server error", + }, + }, + { + id: "getUsers:200-json-example", + type: "json", + route: "getUsers", + delay: null, + disabled: false, + preview: { + status: 200, + body: [ + { + success: true, + firstName: "Joe", + lastName: "Doe", + email: "joe.doe@example.com", + role: "developer", + }, + ], + }, + }, + { + id: "getUsers:418-text-example", + type: "text", + route: "getUsers", + delay: null, + disabled: false, + preview: { + status: 418, + body: "I'm", + }, + }, + { + id: "getUsers:500-text-example", + type: "text", + route: "getUsers", + delay: null, + disabled: false, + preview: { + status: 500, + body: "internal server error", + }, + }, + { + id: "getUser:200-json-example", + type: "json", + route: "getUser", + delay: null, + disabled: false, + preview: { + status: 200, + body: { + success: true, + firstName: "Joe", + lastName: "Doe", + email: "joe.doe@example.com", + role: "developer", + }, + }, + }, + { + id: "getUser:404-json-example", + type: "json", + route: "getUser", + delay: null, + disabled: false, + preview: { + status: 404, + body: { + code: 404, + message: "Not found", + }, + }, + }, + { + id: "getUser:406-json-example", + type: "json", + route: "getUser", + delay: null, + disabled: false, + preview: { + status: 406, + body: [406], + }, + }, + ]); + }); + }); +}); diff --git a/packages/plugin-openapi/test/fixtures/example-in-schema/openapi/api.js b/packages/plugin-openapi/test/fixtures/example-in-schema/openapi/api.js new file mode 100644 index 00000000..05c03714 --- /dev/null +++ b/packages/plugin-openapi/test/fixtures/example-in-schema/openapi/api.js @@ -0,0 +1,592 @@ +module.exports = [ + { + basePath: "/api", + document: { + openapi: "3.0.0", + info: { + title: "Testing API", + version: "1.0.0", + description: "OpenApi document to create mock for testing purpses", + contact: { + email: "info@mocks-server.org", + }, + }, + paths: { + "/info/version": { + get: { + operationId: "infoVersion", + tags: ["Info"], + summary: "Server version", + description: "Returns the server version running", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + example: true, + }, + }, + }, + }, + }, + }, + 400: { + description: "Error", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + }, + }, + example: { + success: false, + }, + }, + }, + }, + }, + }, + }, + }, + + "/server/version": { + get: { + operationId: "serverVersion", + tags: ["Info"], + summary: "Server version", + description: "Returns the server version running", + responses: { + 200: { + $ref: "#/components/responses/VersionResponse", + }, + 400: { + $ref: "#/components/responses/BadRequest", + }, + 405: { + description: "Invalid schema", + content: { + "text/plain": { + schema: {}, + }, + }, + }, + 406: { + description: "Invalid schema: mixed allOf", + content: { + "application/json": { + schema: { + allOf: [ + { + type: "object", + }, + { + type: "array", + }, + { + type: "string", + example: "Invalid", + }, + ], + }, + }, + }, + }, + 407: { + description: "AllOf with missing properties", + content: { + "application/json": { + schema: { + allOf: [ + { + type: "object", + properties: { + prop1: { + type: "string", + example: "prop1", + }, + }, + }, + { + type: "object", + }, + { + type: "object", + properties: { + prop3: { + type: "string", + example: "prop3", + }, + }, + }, + ], + }, + }, + }, + }, + 408: { + description: "Object without examples", + content: { + "application/json": { + schema: { + allOf: [ + { + type: "object", + properties: { + prop1: { + type: "string", + }, + }, + }, + { + type: "object", + properties: { + prop2: { + type: "string", + }, + }, + }, + { + type: "object", + properties: { + prop3: { + type: "string", + }, + }, + }, + ], + }, + }, + }, + }, + 409: { + description: "Additional properties without examples", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + }, + }, + 410: { + description: "Additional properties with inline example", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "string", + example: "Hello", + }, + }, + }, + }, + }, + 411: { + description: "Additional properties with example override", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "string", + example: "ignored", + }, + example: { + en: "Hello", + fr: "Bonjour", + }, + }, + }, + }, + }, + 412: { + description: "Additional properties as boolean", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: true, + }, + }, + }, + }, + 413: { + description: "Additional properties as boolean", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: false, + }, + }, + }, + }, + 414: { + description: "No schema type", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: {}, + }, + }, + }, + }, + 415: { + description: "Array example without items", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "array", + }, + }, + }, + }, + }, + 416: { + description: "Array example", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "array", + items: { + type: "string", + format: "uuid", + }, + }, + }, + }, + }, + }, + 417: { + description: "String with format", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "string", + format: "password", + }, + }, + }, + }, + }, + 418: { + description: "Array example", + content: { + "text/plain": { + schema: { + type: "object", + additionalProperties: { + type: "array", + items: {}, + }, + }, + }, + }, + }, + 421: { + description: "Array example without items (2)", + content: { + "application/json": { + schema: { + type: "array", + }, + }, + }, + }, + 422: { + description: "Invalid type", + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: { + type: "invalid", + }, + }, + }, + }, + }, + 423: { + description: "Example type with format", + content: { + "text/plain": { + schema: { + type: "string", + format: "hostname", + }, + }, + }, + }, + 424: { + description: "Example type with format", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { + type: "string", + }, + }, + }, + }, + }, + }, + 500: { + description: "internal error", + content: { + "text/plain": { + schema: { + anyOf: [ + { + type: "string", + example: "Internal server error", + }, + { + type: "string", + example: "server is busy", + }, + ], + }, + }, + }, + }, + }, + }, + }, + + "/users": { + get: { + operationId: "getUsers", + summary: "Return all users", + responses: { + 200: { + description: "List of users", + content: { + "application/json": { + schema: { + type: "array", + items: { + $ref: "#/components/schemas/User", + }, + }, + }, + }, + }, + 418: { + description: "I'm a teapot", + content: { + "text/plain": { + schema: { + oneOf: [ + { + type: "string", + example: "I'm", + }, + { + type: "string", + example: "a", + }, + { + type: "string", + example: "teapot", + }, + ], + }, + }, + }, + }, + 500: { + description: "server error", + content: { + "text/plain": { + schema: { + type: "string", + example: "internal server error", + }, + }, + }, + }, + }, + }, + }, + + "/users/{id}": { + get: { + operationId: "getUser", + parameters: [ + { + name: "id", + in: "path", + description: "ID the user", + required: true, + schema: { + type: "string", + }, + }, + ], + summary: "Return one user", + responses: { + 200: { + description: "user found", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/User", + }, + }, + }, + }, + 404: { + description: "user not found", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/NotFound", + }, + }, + }, + }, + 405: { + description: "Testing primitive without example or enum", + content: { + "application/json": { + schema: { + type: "string", + }, + }, + }, + }, + 406: { + description: "Testing primitive array", + content: { + "application/json": { + schema: { + type: "array", + items: { + type: "number", + example: 406, + }, + }, + }, + }, + }, + }, + }, + }, + }, + + components: { + responses: { + VersionResponse: { + description: "Success", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + example: true, + }, + }, + }, + }, + }, + }, + + BadRequest: { + description: "Error", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + example: false, + }, + }, + }, + }, + }, + }, + }, + + schemas: { + User: { + allOf: [ + { + $ref: "#/components/schemas/SuccessResponse", + }, + { + type: "object", + properties: { + firstName: { + type: "string", + example: "Joe", + }, + lastName: { + type: "string", + example: "Doe", + }, + email: { + type: "string", + format: "email", + example: "joe.doe@example.com", + }, + role: { + type: "string", + enum: ["developer", "maintainer", "admin"], + }, + }, + }, + ], + }, + NotFound: { + type: "object", + properties: { + code: { + type: "integer", + format: "int32", + example: 404, + }, + message: { + type: "string", + example: "Not found", + }, + }, + }, + SuccessResponse: { + type: "object", + properties: { + success: { + type: "boolean", + example: true, + }, + }, + }, + }, + }, + }, + }, +];