diff --git a/src/__tests__/__snapshots__/gen-api-models-oas3.test.ts.snap b/src/__tests__/__snapshots__/gen-api-models-oas3.test.ts.snap index 6a78104b..5558f836 100644 --- a/src/__tests__/__snapshots__/gen-api-models-oas3.test.ts.snap +++ b/src/__tests__/__snapshots__/gen-api-models-oas3.test.ts.snap @@ -170,7 +170,7 @@ exports[`gen-api-models should generate the operator definition 1`] = ` */ // Request type definition - export type TestAuthBearerT = r.IGetApiRequestType<{readonly bearerToken: string}, \\"Authorization\\", never, r.IResponseType<200, undefined>|r.IResponseType<403, undefined>>; + export type TestAuthBearerT = r.IGetApiRequestType<{readonly bearerToken: string,readonly qo?: string,readonly qr: string}, \\"Authorization\\", never, r.IResponseType<200, undefined>|r.IResponseType<403, undefined>>; // Decodes the success response with a custom success type export function testAuthBearerDecoder(type: t.Type) { return r.composeResponseDecoders(r.ioResponseDecoder<200, (typeof type)[\\"_A\\"], (typeof type)[\\"_O\\"]>(200, type), r.constantResponseDecoder(403, undefined)); } @@ -500,7 +500,7 @@ exports[`gen-api-models should support file uploads 1`] = ` */ // Request type definition - export type TestFileUploadT = r.IPostApiRequestType<{}, never, never, r.IResponseType<200, undefined>>; + export type TestFileUploadT = r.IPostApiRequestType<{readonly file: { uri: string, name: string, type: string }}, \\"Content-Type\\", never, r.IResponseType<200, undefined>>; // Decodes the success response with a custom success type export function testFileUploadDecoder(type: t.Type) { return r.ioResponseDecoder<200, (typeof type)[\\"_A\\"], (typeof type)[\\"_O\\"]>(200, type); } @@ -508,3 +508,35 @@ exports[`gen-api-models should support file uploads 1`] = ` // Decodes the success response with the type defined in the specs export const testFileUploadDefaultDecoder = () => testFileUploadDecoder(t.undefined);" `; + +exports[`gen-api-models should support generate requestbody 1`] = ` +" + /**************************************************************** + * testRequestBody + */ + + // Request type definition + export type TestRequestBodyT = r.IPostApiRequestType<{readonly problem?: Problem}, \\"Content-Type\\", never, r.IResponseType<204, undefined>>; + + // Decodes the success response with a custom success type + export function testRequestBodyDecoder(type: t.Type) { return r.ioResponseDecoder<204, (typeof type)[\\"_A\\"], (typeof type)[\\"_O\\"]>(204, type); } + + // Decodes the success response with the type defined in the specs + export const testRequestBodyDefaultDecoder = () => testRequestBodyDecoder(t.undefined);" +`; + +exports[`gen-api-models should support generate serializers 1`] = ` +" + /**************************************************************** + * testSerializer + */ + + // Request type definition + export type TestSerializerT = r.IPostApiRequestType<{readonly qo?: string,readonly paginationRequest?: string}, \\"Content-Type\\", never, r.IResponseType<200, Message>|r.IResponseType<403, Problem>>; + + // Decodes the success response with a custom success type + export function testSerializerDecoder(type: t.Type) { return r.composeResponseDecoders(r.ioResponseDecoder<200, (typeof type)[\\"_A\\"], (typeof type)[\\"_O\\"]>(200, type), r.ioResponseDecoder<403, (typeof Problem)[\\"_A\\"], (typeof Problem)[\\"_O\\"]>(403, Problem)); } + + // Decodes the success response with the type defined in the specs + export const testSerializerDefaultDecoder = () => testSerializerDecoder(Message);" +`; diff --git a/src/__tests__/__snapshots__/gen-api-models.test.ts.snap b/src/__tests__/__snapshots__/gen-api-models.test.ts.snap index 3fa0ca2f..5558f836 100644 --- a/src/__tests__/__snapshots__/gen-api-models.test.ts.snap +++ b/src/__tests__/__snapshots__/gen-api-models.test.ts.snap @@ -508,3 +508,35 @@ exports[`gen-api-models should support file uploads 1`] = ` // Decodes the success response with the type defined in the specs export const testFileUploadDefaultDecoder = () => testFileUploadDecoder(t.undefined);" `; + +exports[`gen-api-models should support generate requestbody 1`] = ` +" + /**************************************************************** + * testRequestBody + */ + + // Request type definition + export type TestRequestBodyT = r.IPostApiRequestType<{readonly problem?: Problem}, \\"Content-Type\\", never, r.IResponseType<204, undefined>>; + + // Decodes the success response with a custom success type + export function testRequestBodyDecoder(type: t.Type) { return r.ioResponseDecoder<204, (typeof type)[\\"_A\\"], (typeof type)[\\"_O\\"]>(204, type); } + + // Decodes the success response with the type defined in the specs + export const testRequestBodyDefaultDecoder = () => testRequestBodyDecoder(t.undefined);" +`; + +exports[`gen-api-models should support generate serializers 1`] = ` +" + /**************************************************************** + * testSerializer + */ + + // Request type definition + export type TestSerializerT = r.IPostApiRequestType<{readonly qo?: string,readonly paginationRequest?: string}, \\"Content-Type\\", never, r.IResponseType<200, Message>|r.IResponseType<403, Problem>>; + + // Decodes the success response with a custom success type + export function testSerializerDecoder(type: t.Type) { return r.composeResponseDecoders(r.ioResponseDecoder<200, (typeof type)[\\"_A\\"], (typeof type)[\\"_O\\"]>(200, type), r.ioResponseDecoder<403, (typeof Problem)[\\"_A\\"], (typeof Problem)[\\"_O\\"]>(403, Problem)); } + + // Decodes the success response with the type defined in the specs + export const testSerializerDefaultDecoder = () => testSerializerDecoder(Message);" +`; diff --git a/src/__tests__/api.yaml b/src/__tests__/api.yaml index fec97352..e114fa46 100644 --- a/src/__tests__/api.yaml +++ b/src/__tests__/api.yaml @@ -45,7 +45,42 @@ paths: responses: "200": description: "File uploaded" + /test-serializers: + post: + operationId: "testSerializer" + parameters: + - name: "qo" + in: "query" + required: false + type: "string" + - $ref: "#/parameters/PaginationRequest" + responses: + "200": + description: "File uploaded" + schema: + $ref: "#/definitions/Message" + "403": + description: "Error string" + schema: + $ref: "#/definitions/Problem" + /test-requestbody: + post: + operationId: "testRequestBody" + parameters: + - in: body + name: body + schema: + "$ref": "#/definitions/Problem" + responses: + "204": + description: No content definitions: + Problem: + properties: + title: + type: string + type: + type: string AllOfTest: allOf: - type: object diff --git a/src/__tests__/api_oas3.yaml b/src/__tests__/api_oas3.yaml index 9e3c66d2..524c0a39 100644 --- a/src/__tests__/api_oas3.yaml +++ b/src/__tests__/api_oas3.yaml @@ -29,6 +29,7 @@ paths: post: operationId: testFileUpload requestBody: + required: true content: multipart/form-data: schema: @@ -42,6 +43,40 @@ paths: responses: "200": description: File uploaded + /test-serializers: + post: + operationId: "testSerializer" + parameters: + - name: "qo" + in: "query" + required: false + schema: + type: "string" + - $ref: "#/components/parameters/PaginationRequest" + responses: + "200": + description: "File uploaded" + content: + application/json: + schema: + $ref: "#/components/schemas/Message" + "403": + description: "Error string" + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + /test-requestbody: + post: + operationId: "testRequestBody" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/Problem" + responses: + "204": + description: No content servers: - url: https://localhost/api/v1 components: @@ -79,6 +114,12 @@ components: write: Grants write access admin: Grants access to admin operations schemas: + Problem: + properties: + title: + type: string + type: + type: string AllOfTest: allOf: - type: object @@ -264,4 +305,3 @@ components: - is_email_set - name - version - diff --git a/src/__tests__/gen-api-models-oas3.test.ts b/src/__tests__/gen-api-models-oas3.test.ts index 146aa8b5..7149f590 100644 --- a/src/__tests__/gen-api-models-oas3.test.ts +++ b/src/__tests__/gen-api-models-oas3.test.ts @@ -273,7 +273,26 @@ describe("gen-api-models", () => { "post", operation.operationId, operation, - spec.parameters, + spec.components.parameters, + spec.components.securitySchemes, + [], + {}, + "undefined", + "undefined", + true + ); + + expect(code.e1).toMatchSnapshot(); + }); + + it("should support generate serializers", async () => { + const operation = spec.paths["/test-serializers"].post; + + const code = await renderOperation( + "post", + operation.operationId, + operation, + spec.components.parameters, spec.components.securitySchemes, [], {}, @@ -284,4 +303,24 @@ describe("gen-api-models", () => { expect(code.e1).toMatchSnapshot(); }); + + it("should support generate requestbody", async () => { + const operation = spec.paths["/test-requestbody"].post; + + const code = await renderOperation( + "post", + operation.operationId, + operation, + spec.components.parameters, + spec.components.securitySchemes, + [], + {}, + "undefined", + "undefined", + true + ); + + expect(code.e1).toMatchSnapshot(); + }); + }); diff --git a/src/__tests__/gen-api-models.test.ts b/src/__tests__/gen-api-models.test.ts index 059131e2..a1fc0d74 100644 --- a/src/__tests__/gen-api-models.test.ts +++ b/src/__tests__/gen-api-models.test.ts @@ -284,4 +284,43 @@ describe("gen-api-models", () => { expect(code.e1).toMatchSnapshot(); }); + + it("should support generate serializers", async () => { + const operation = spec.paths["/test-serializers"].post; + + const code = await renderOperation( + "post", + operation.operationId, + operation, + spec.parameters, + spec.securityDefinitions, + [], + {}, + "undefined", + "undefined", + true + ); + + expect(code.e1).toMatchSnapshot(); + }); + + it("should support generate requestbody", async () => { + const operation = spec.paths["/test-requestbody"].post; + + const code = await renderOperation( + "post", + operation.operationId, + operation, + spec.parameters, + spec.securityDefinitions, + [], + {}, + "undefined", + "undefined", + true + ); + + expect(code.e1).toMatchSnapshot(); + }); + }); diff --git a/src/gen-api-models.ts b/src/gen-api-models.ts index 1946278c..a536675f 100644 --- a/src/gen-api-models.ts +++ b/src/gen-api-models.ts @@ -62,13 +62,25 @@ function typeFromRef( s: string ): ITuple2<"definition" | "parameter" | "other", string> | undefined { const parts = s.split("/"); + + if (!parts) { + return undefined; + } + + // If it's an OAS3, remove the "components" part. + if (parts[1] === "components") { + parts.splice(1,1); + } + if (parts && parts.length === 3) { const refType: "definition" | "parameter" | "other" = parts[1] === "definitions" ? "definition" - : parts[1] === "parameters" - ? "parameter" - : "other"; + : parts[1] === "schemas" + ? "definition" + : parts[1] === "parameters" + ? "parameter" + : "other"; return Tuple2(refType, parts[2]); } return undefined; @@ -96,6 +108,74 @@ function getDecoderForResponse(status: string, type: string): string { } } +/** Get the first schema from an OAS3 request or response body + */ +function getSchemaFromBody( + item: any, +): OpenAPIV3.BaseSchemaObject | string | undefined { + + try { + const content = item.content; + const media_types = Object.keys(content); + + if (media_types.length == 0) { + console.warn(`Missing media-type in ${JSON.stringify(item)}`); + return undefined; + } + + if (media_types.length > 1) { + console.warn(`Multiple media-types in ${JSON.stringify(item)}`); + return undefined; + } + + const media_type = content[media_types[0]]; + return "$ref" in media_type.schema ? media_type.schema.$ref : media_type.schema; + + } catch { + console.warn(`Cannot get schema from body: ${JSON.stringify(item)}`); + return undefined; + } +} + +/** + * Convert an OAS3 Object to typescript. + * This function supports only one level of schema properties: + * for nested schemas define a schema object. + */ +function specObjectToTs( + item: any +) : string | undefined { + + if ((item.properties || item.type) == false) { + return undefined; + } + + if (item.properties) { + item = item.properties; + + // File upload implementation used in io-utils. + const file_upload_schema = {"file": {"type": "string", "format": "binary"}} + if (JSON.stringify(item) == JSON.stringify(file_upload_schema)) { + console.log(`Found file upload pattern`); + return specTypeToTs("file"); + } + + for (let p in item) { + item[p] = item[p].type; + } + return JSON.stringify(item).replace(/"/g, " "); + } + + if (item.type) { + // Support for generic OAS3 binary file upload + // see https://swagger.io/docs/specification/describing-request-body/file-upload/ + if (item.type == "string" && item.format == "binary") { + return specTypeToTs("file"); + } + return specTypeToTs(item.type); + } +} + export function renderOperation( method: string, operationId: string, @@ -115,16 +195,58 @@ export function renderOperation( const requestType = `r.I${capitalize(method)}ApiRequestType`; const params: { [key: string]: string } = {}; const importedTypes = new Set(); + + // Eventually process requestBody + if (isV3OperationWithBody(operation) && operation.requestBody !== undefined) { + const item = operation.requestBody; + const typeRefOrSchema = getSchemaFromBody(item); + const isParamRequired = (item as OpenAPIV3.RequestBodyObject).required === true; + if (typeRefOrSchema) { + const inlineRequestBody = specObjectToTs(typeRefOrSchema); + + // parameter is defined inline + if (inlineRequestBody){ + const schema = (typeRefOrSchema as OpenAPIV3.BaseSchemaObject); + const parameterName = schema.properties + ? schema.properties.file + ? "file" + : "body" + : "body"; + params[`${parameterName}${isParamRequired ? "" : "?"}`] = inlineRequestBody; + } else { // parameter is in $ref + const schema = (typeRefOrSchema as string); + const parsedRef = typeRefOrSchema ? typeFromRef(schema) : undefined; + console.debug(`requestBody.typeRef ${ JSON.stringify({'1': parsedRef, '2': typeRefOrSchema}) }`); + + if (parsedRef) { + const refType = parsedRef.e1; + const paramName = `${uncapitalize(parsedRef.e2)}${ + isParamRequired ? "" : "?" + }`; + + params[paramName] = parsedRef.e2; + if (refType === "definition") { + importedTypes.add(parsedRef.e2); + } + } + } + } else { + console.warn(`Cannot extract type from ref [${typeRefOrSchema}]`); + } + } + + // Process ordinary parameters if (operation.parameters !== undefined) { const parameters = operation.parameters as Array< OpenAPIV2.InBodyParameterObject | OpenAPIV3.ParameterObject >; parameters.forEach(param => { - if (param.name && (param as any).type) { + if (param.name && getParameterType(param)) { // The parameter description is inline + // and the parameter type is not undefined. const isRequired = param.required === true; params[`${param.name}${isRequired ? "" : "?"}`] = specTypeToTs( - (param as any).type + getParameterType(param)! ); return; } @@ -148,11 +270,17 @@ export function renderOperation( console.warn(`Unrecognized ref type [${refInParam}]`); return; } + // if the reference type is "definition" + // e2 contains a schema object + // otherwise it is the schema name const paramType: string | undefined = refType === "definition" ? parsedRef.e2 - : specParameters - ? specTypeToTs((specParameters as any)[parsedRef.e2].type) + // check that specParameters contain a valid declaration too! + : specParameters && getParameterType((specParameters as any)[parsedRef.e2]) + ? specTypeToTs( + getParameterType((specParameters as any)[parsedRef.e2])! + ) : undefined; if (paramType === undefined) { @@ -217,7 +345,13 @@ export function renderOperation( const responses = Object.keys(operation.responses as object).map( responseStatus => { const response = operation.responses![responseStatus]; - const typeRef = response.schema ? response.schema.$ref : undefined; + const typeRef = + // get schema from Swagger... + response.schema + ? response.schema.$ref + // ... or try with OAS3 + : getSchemaFromBody(response); + const parsedRef = typeRef ? typeFromRef(typeRef) : undefined; if (parsedRef !== undefined) { importedTypes.add(parsedRef.e2); @@ -273,6 +407,20 @@ export function renderOperation( return Tuple2(code, importedTypes); } +function getParameterType( + parameter: + any | undefined +): string | undefined { + if (!parameter) { + return undefined; + } + return parameter.type + ? parameter.type + : parameter.schema + ? parameter.schema.type + : undefined; +} + function getAuthHeaders( securityDefinitions: | OpenAPIV2.SecurityDefinitionsObject @@ -349,6 +497,12 @@ export function isOpenAPIV3( return specs.hasOwnProperty("openapi"); } +export function isV3OperationWithBody( + item: any +): item is OpenAPIV3.OperationObject { + return item.hasOwnProperty("requestBody"); +} + export async function generateApi( env: nunjucks.Environment, specFilePath: string,