diff --git a/bin/index.js b/bin/index.js index ed8b0889b..5af29c8fe 100755 --- a/bin/index.js +++ b/bin/index.js @@ -20,6 +20,7 @@ const params = program .option('--exportServices ', 'Write services to disk', true) .option('--exportModels ', 'Write models to disk', true) .option('--exportSchemas ', 'Write schemas to disk', false) + .option('--useDateType', 'Output Date instead of string for the format "date-time" in the models') .option('--indent ', 'Indentation options [4, 2, tabs]', '4') .option('--postfix ', 'Deprecated: Use --postfixServices instead. Service name postfix', 'Service') .option('--postfixServices ', 'Service name postfix', 'Service') @@ -42,6 +43,7 @@ if (OpenAPI) { exportServices: JSON.parse(params.exportServices) === true, exportModels: JSON.parse(params.exportModels) === true, exportSchemas: JSON.parse(params.exportSchemas) === true, + useDateType: JSON.parse(params.useDateType) === true, indent: params.indent, postfixServices: params.postfixServices ?? params.postfix, postfixModels: params.postfixModels, diff --git a/package.json b/package.json index 973a1cde2..bb13279ce 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:e2e": "jest --selectProjects E2E --runInBand --verbose", "eslint": "eslint .", "eslint:fix": "eslint . --fix", + "prepare": "npm run release", "prepublishOnly": "npm run clean && npm run release", "codecov": "codecov --token=66c30c23-8954-4892-bef9-fbaed0a2e42b" }, diff --git a/src/index.ts b/src/index.ts index e63919085..23d25bcec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export type Options = { postfixModels?: string; request?: string; write?: boolean; + useDateType?: boolean; }; /** @@ -49,6 +50,7 @@ export type Options = { * @param postfixModels Model name postfix * @param request Path to custom request file * @param write Write the files to disk (true or false) + * @param useDateType: Output Date instead of string with format date-time */ export const generate = async ({ input, @@ -66,6 +68,7 @@ export const generate = async ({ postfixModels = '', request, write = true, + useDateType = false }: Options): Promise => { const openApi = isString(input) ? await getOpenApiSpec(input) : input; const openApiVersion = getOpenApiVersion(openApi); @@ -94,6 +97,7 @@ export const generate = async ({ indent, postfixServices, postfixModels, + useDateType, clientName, request ); @@ -118,6 +122,7 @@ export const generate = async ({ indent, postfixServices, postfixModels, + useDateType, clientName, request ); diff --git a/src/utils/dateTypeOverride.spec.ts b/src/utils/dateTypeOverride.spec.ts new file mode 100644 index 000000000..134dfd432 --- /dev/null +++ b/src/utils/dateTypeOverride.spec.ts @@ -0,0 +1,142 @@ +import { dateTypeOverride, RequiredFields } from './dateTypeOverride'; + +describe('dateTypeOverride', () => { + it('should replace the base type for the model combination {base: "string", format: "data-time"}', async () => { + const expected = JSON.parse(JSON.stringify(models)) as RequiredFields[]; + expected[1].properties[1].base = 'Date'; + if (expected[3].link?.properties[0].base) { + expected[3].link.properties[0].base = 'Date'; + } + + const result = dateTypeOverride(models); + expect(result).toEqual(expected); + }); +}); + +type ModelOnlyName = { name: string }; + +const baseModel: Omit, 'export' | 'base' | 'name'> = { + link: null, + properties: [], +}; + +const models: RequiredFields[] = [ + { + ...baseModel, + name: 'ParentType', + export: 'interface', + base: 'any', + properties: [ + { + ...baseModel, + name: 'name', + export: 'interface', + base: 'any', + }, + ], + }, + { + ...baseModel, + name: 'ExampleType', + export: 'interface', + base: 'any', + properties: [ + { + ...baseModel, + name: 'id', + export: 'generic', + base: 'number', + }, + { + ...baseModel, + name: 'dateTime', + export: 'generic', + base: 'string', + format: 'date-time', + }, + { + ...baseModel, + name: 'date', + export: 'generic', + base: 'string', + format: 'date', + }, + { + ...baseModel, + name: 'dateTimeNullable', + export: 'generic', + base: 'string', + format: 'date', + }, + { + ...baseModel, + name: 'dateNullable', + export: 'generic', + base: 'string', + format: 'date', + }, + ], + }, + { + ...baseModel, + name: 'InheritType', + export: 'all-of', + base: 'any', + properties: [ + { + ...baseModel, + name: '', + export: 'reference', + base: 'ParentType', + }, + { + ...baseModel, + name: '', + export: 'reference', + base: 'ExampleType', + }, + ], + }, + { + ...baseModel, + name: 'WrappedInArray', + export: 'array', + base: 'any', + link: { + ...baseModel, + name: '', + export: 'interface', + base: 'any', + properties: [ + { + ...baseModel, + name: 'dateTime', + export: 'generic', + base: 'string', + format: 'date-time', + }, + { + ...baseModel, + name: 'date', + export: 'generic', + base: 'string', + format: 'date', + }, + { + ...baseModel, + name: 'dateTimeNullable', + export: 'generic', + base: 'string', + format: 'date', + }, + { + ...baseModel, + name: 'dateNullable', + export: 'generic', + base: 'string', + format: 'date', + }, + ], + }, + }, +]; diff --git a/src/utils/dateTypeOverride.ts b/src/utils/dateTypeOverride.ts new file mode 100644 index 000000000..6cb46e147 --- /dev/null +++ b/src/utils/dateTypeOverride.ts @@ -0,0 +1,39 @@ +import { Model } from '../client/interfaces/Model.d'; + +const formatDate = ['date-time']; +export type RequiredFields = Pick & { properties: RequiredFields[]; link: RequiredFields | null } & T; +/** + * Change Model.base if it is a string and has the format 'date-time' + * @param models Array of Models + */ +export function dateTypeOverride(models: RequiredFields[]): RequiredFields[] { + return models.map(model => { + if (model.export === 'interface') { + return { ...model, properties: dateTypeOverride(model.properties) }; + } + + if (model.export === 'array') { + if (model.link !== null) { + const link = cloneObject(model.link); + link.properties = dateTypeOverride(link.properties); + return { ...model, link }; + } + return { ...model }; + } + + if (model.base !== 'string' || model.format === undefined) { + return { ...model }; + } + + let base = model.base; + if (formatDate.includes(model.format)) { + base = 'Date'; + } + + return { ...model, base }; + }); +} + +function cloneObject(object: T): T { + return { ...object }; +} diff --git a/src/utils/writeClient.spec.ts b/src/utils/writeClient.spec.ts index 3c06a95a5..634a1651c 100644 --- a/src/utils/writeClient.spec.ts +++ b/src/utils/writeClient.spec.ts @@ -49,7 +49,8 @@ describe('writeClient', () => { true, Indent.SPACE_4, 'Service', - 'AppClient' + 'AppClient', + false ); expect(rmdir).toBeCalled(); diff --git a/src/utils/writeClient.ts b/src/utils/writeClient.ts index cea2f3d88..52af574d0 100644 --- a/src/utils/writeClient.ts +++ b/src/utils/writeClient.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import type { Client } from '../client/interfaces/Client'; import type { HttpClient } from '../HttpClient'; import type { Indent } from '../Indent'; +import { dateTypeOverride } from './dateTypeOverride'; import { mkdir, rmdir } from './fileSystem'; import { isDefined } from './isDefined'; import { isSubDirectory } from './isSubdirectory'; @@ -31,6 +32,7 @@ import { writeClientServices } from './writeClientServices'; * @param postfixServices Service name postfix * @param postfixModels Model name postfix * @param clientName Custom client class name + * @param useDateType Output Date instead of string with format date-time * @param request Path to custom request file */ export const writeClient = async ( @@ -47,6 +49,7 @@ export const writeClient = async ( indent: Indent, postfixServices: string, postfixModels: string, + useDateType: boolean, clientName?: string, request?: string ): Promise => { @@ -91,6 +94,9 @@ export const writeClient = async ( if (exportModels) { await rmdir(outputPathModels); await mkdir(outputPathModels); + if (useDateType) { + client.models = dateTypeOverride(client.models); + } await writeClientModels(client.models, templates, outputPathModels, httpClient, useUnionTypes, indent); } diff --git a/test/__snapshots__/index.spec.ts.snap b/test/__snapshots__/index.spec.ts.snap index eefa4ffe0..edd082b07 100644 --- a/test/__snapshots__/index.spec.ts.snap +++ b/test/__snapshots__/index.spec.ts.snap @@ -3093,6 +3093,427 @@ export class TypesService { " `; +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/core/ApiError.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + + constructor(response: ApiResult, message: string) { + super(message); + + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + } +}" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/core/ApiRequestOptions.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export interface ApiRequestOptions { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly path: string; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly responseHeader?: string; + readonly errors?: Record; +}" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/core/ApiResult.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export interface ApiResult { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/core/OpenAPI.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +interface Config { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + TOKEN: string | (() => Promise); +} + +export const OpenAPI: Config = { + BASE: '/api/v1', + VERSION: '1.0', + WITH_CREDENTIALS: false, + TOKEN: '', +};" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/core/request.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { OpenAPI } from './OpenAPI'; + +function isDefined(value: T | null | undefined): value is Exclude { + return value !== undefined && value !== null; +} + +function isString(value: any): value is string { + return typeof value === 'string'; +} + +function isBlob(value: any): value is Blob { + return value instanceof Blob; +} + +function getQueryString(params: Record): string { + const qs: string[] = []; + Object.keys(params).forEach(key => { + const value = params[key]; + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(value => { + qs.push(\`\${encodeURIComponent(key)}=\${encodeURIComponent(String(value))}\`); + }); + } else { + qs.push(\`\${encodeURIComponent(key)}=\${encodeURIComponent(String(value))}\`); + } + } + }); + if (qs.length > 0) { + return \`?\${qs.join('&')}\`; + } + return ''; +} + +function getUrl(options: ApiRequestOptions): string { + const path = options.path.replace(/[:]/g, '_'); + const url = \`\${OpenAPI.BASE}\${path}\`; + + if (options.query) { + return \`\${url}\${getQueryString(options.query)}\`; + } + return url; +} + +function getFormData(params: Record): FormData { + const formData = new FormData(); + Object.keys(params).forEach(key => { + const value = params[key]; + if (isDefined(value)) { + formData.append(key, value); + } + }); + return formData; +} + +async function getToken(): Promise { + if (typeof OpenAPI.TOKEN === 'function') { + return OpenAPI.TOKEN(); + } + return OpenAPI.TOKEN; +} + +async function getHeaders(options: ApiRequestOptions): Promise { + const headers = new Headers({ + Accept: 'application/json', + ...options.headers, + }); + + const token = await getToken(); + if (isDefined(token) && token !== '') { + headers.append('Authorization', \`Bearer \${token}\`); + } + + if (options.body) { + if (isBlob(options.body)) { + headers.append('Content-Type', options.body.type || 'application/octet-stream'); + } else if (isString(options.body)) { + headers.append('Content-Type', 'text/plain'); + } else { + headers.append('Content-Type', 'application/json'); + } + } + return headers; +} + +function getRequestBody(options: ApiRequestOptions): BodyInit | undefined { + if (options.formData) { + return getFormData(options.formData); + } + if (options.body) { + if (isString(options.body) || isBlob(options.body)) { + return options.body; + } else { + return JSON.stringify(options.body); + } + } + return undefined; +} + +async function sendRequest(options: ApiRequestOptions, url: string): Promise { + const request: RequestInit = { + method: options.method, + headers: await getHeaders(options), + body: getRequestBody(options), + }; + return await fetch(url, request); +} + +function getResponseHeader(response: Response, responseHeader?: string): string | null { + if (responseHeader) { + const content = response.headers.get(responseHeader); + if (isString(content)) { + return content; + } + } + return null; +} + +async function getResponseBody(response: Response): Promise { + try { + const contentType = response.headers.get('Content-Type'); + if (contentType) { + const isJSON = contentType.toLowerCase().startsWith('application/json'); + if (isJSON) { + return await response.json(); + } else { + return await response.text(); + } + } + } catch (error) { + console.error(error); + } + return null; +} + +function catchErrors(options: ApiRequestOptions, result: ApiResult): void { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(result, error); + } + + if (!result.ok) { + throw new ApiError(result, 'Generic Error'); + } +} + +/** + * Request using fetch client + * @param options The request options from the the service + * @result ApiResult + * @throws ApiError + */ +export async function request(options: ApiRequestOptions): Promise { + const url = getUrl(options); + const response = await sendRequest(options, url); + const responseBody = await getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: responseHeader || responseBody, + }; + + catchErrors(options, result); + return result; +} + +" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/index.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { OpenAPI } from './core/OpenAPI'; + +export type { ExampleType } from './models/ExampleType'; +export type { InheritType } from './models/InheritType'; +export type { ParentType } from './models/ParentType'; +export type { WrappedInArray } from './models/WrappedInArray'; + +export { $ExampleType } from './schemas/$ExampleType'; +export { $InheritType } from './schemas/$InheritType'; +export { $ParentType } from './schemas/$ParentType'; +export { $WrappedInArray } from './schemas/$WrappedInArray'; +" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/models/ExampleType.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export interface ExampleType { + id?: number; + dateTime?: Date; + date?: string; + dateTimeNullable?: string | null; + dateNullable?: string | null; +} +" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/models/InheritType.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ExampleType } from './ExampleType'; +import type { ParentType } from './ParentType'; + +export interface InheritType extends ParentType, ExampleType { +} +" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/models/ParentType.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export interface ParentType { + name?: any; +} +" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/models/WrappedInArray.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WrappedInArray = Array<{ + dateTime?: Date, + date?: string, + dateTimeNullable?: string | null, + dateNullable?: string | null, +}>;" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/schemas/$ExampleType.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ExampleType = { + properties: { + id: { + type: 'number', + }, + dateTime: { + type: 'string', + format: 'date-time', + }, + date: { + type: 'string', + format: 'date', + }, + dateTimeNullable: { + type: 'string', + isNullable: true, + format: 'date', + }, + dateNullable: { + type: 'string', + isNullable: true, + format: 'date', + }, + }, +};" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/schemas/$InheritType.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import { $ParentType } from './$ParentType'; +import { $ExampleType } from './$ExampleType'; + +export const $InheritType = { + properties: { + ...$ParentType.properties, + ...$ExampleType.properties, + }, +};" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/schemas/$ParentType.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $ParentType = { + properties: { + name: { + properties: { + }, + }, + }, +};" +`; + +exports[`v3 datetype should generate files and apply the Date type to interfaces with the string format "date-time": ./test/generated/v3_datetype/schemas/$WrappedInArray.ts 1`] = ` +"/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $WrappedInArray = { + type: 'array', + contains: { + properties: { + dateTime: { + type: 'string', + format: 'date-time', + }, + date: { + type: 'string', + format: 'date', + }, + dateTimeNullable: { + type: 'string', + isNullable: true, + format: 'date', + }, + dateNullable: { + type: 'string', + isNullable: true, + format: 'date', + }, + }, + }, +};" +`; + exports[`v3 should generate: ./test/generated/v3/core/ApiError.ts 1`] = ` "/* istanbul ignore file */ /* tslint:disable */ diff --git a/test/index.spec.ts b/test/index.spec.ts index b8e87da75..71070f579 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -46,3 +46,41 @@ describe('v3', () => { }); }); }); + +describe('v3 datetype', () => { + it('should generate files and apply the Date type to interfaces with the string format "date-time"', async () => { + await OpenAPI.generate({ + input: './test/spec/v3_datetype.yaml', + output: './test/generated/v3_datetype/', + httpClient: OpenAPI.HttpClient.FETCH, + useOptions: true, + useUnionTypes: true, + exportCore: true, + exportSchemas: true, + exportModels: true, + exportServices: true, + useDateType: true, + }); + + glob.sync('./test/generated/v3_datetype/**/*.ts').forEach(file => { + const content = fs.readFileSync(file, 'utf8').toString(); + expect(content).toMatchSnapshot(file); + }); + + glob.sync('./test/generated/v3_datetype/models/*.ts').forEach(file => { + const content = fs.readFileSync(file, 'utf8').toString(); + const getNameAndContentPattern = /export (?:interface|type) (\w+)([\s\S]+)*?\}/; + const regexGroup = getNameAndContentPattern.exec(content); + const interfaceName = regexGroup[1] || ''; + const interfaceContent = regexGroup[2] || ''; + const attribute = interfaceContent + .split('\n') + .map(att => att.trim()) + .find(att => att.startsWith('dateTime')); + if (attribute) { + console.log(`The interface ${interfaceName} with an attribute named 'dateTime' should have the data type of 'Date'`) + expect(attribute).toMatch(/Date/); + } + }); + }); +}); diff --git a/test/spec/v3_datetype.yaml b/test/spec/v3_datetype.yaml new file mode 100644 index 000000000..70693c77c --- /dev/null +++ b/test/spec/v3_datetype.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.1 +info: + title: Data Type Test Spec + version: "1.0" + description: OpenAPI v3 (Swagger) file representing the CoBaLo API +servers: + - url: /api/v1 +paths: +components: + schemas: + ParentType: + title: Parent + type: object + properties: + name: string + ExampleType: + title: ExampleType + type: object + properties: + id: + type: integer + dateTime: + type: string + format: date-time + example: "2012-10-27T17:18:30.966Z" + date: + type: string + format: date + example: "2012-10-27" + dateTimeNullable: + type: string + format: date + nullable: true + example: "2012-10-27" + dateNullable: + type: string + format: date + nullable: true + example: "2012-10-27" + InheritType: + title: InheritType + allOf: + - $ref: "#/components/schemas/ParentType" + - $ref: "#/components/schemas/ExampleType" + WrappedInArray: + title: WrappedInArray + type: array + items: + type: object + properties: + dateTime: + type: string + format: date-time + example: "2012-10-27T17:18:30.966Z" + date: + type: string + format: date + example: "2012-10-27" + dateTimeNullable: + type: string + format: date + nullable: true + example: "2012-10-27" + dateNullable: + type: string + format: date + nullable: true + example: "2012-10-27" + + \ No newline at end of file