From 9a73209d86e8316db694a44dd6720cc5c89f8032 Mon Sep 17 00:00:00 2001 From: crutch12 Date: Wed, 9 Feb 2022 20:47:09 +0300 Subject: [PATCH 1/3] feat(taxios-generate): programmatic usage --- packages/taxios-generate/package.json | 2 + packages/taxios-generate/src/bin.ts | 457 +----------------------- packages/taxios-generate/src/index.ts | 478 ++++++++++++++++++++++++++ 3 files changed, 493 insertions(+), 444 deletions(-) create mode 100644 packages/taxios-generate/src/index.ts diff --git a/packages/taxios-generate/package.json b/packages/taxios-generate/package.json index ce81c13..5b2f75b 100644 --- a/packages/taxios-generate/package.json +++ b/packages/taxios-generate/package.json @@ -14,6 +14,8 @@ "author": "Denis Karabaza ", "homepage": "https://github.com/simplesmiler/taxios/packages/taxios-generate", "license": "ISC", + "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": "dist/bin.js", "repository": { "type": "git", diff --git a/packages/taxios-generate/src/bin.ts b/packages/taxios-generate/src/bin.ts index 93b86a8..b9f4b2b 100644 --- a/packages/taxios-generate/src/bin.ts +++ b/packages/taxios-generate/src/bin.ts @@ -1,218 +1,14 @@ #!/usr/bin/env node -import { promises as outerFs } from 'fs'; -import nodePath from 'path'; -import { ModuleDeclarationKind, OptionalKind, Project, PropertySignatureStructure, Writers } from 'ts-morph'; -import { cloneDeep, sortBy } from 'lodash'; -import { compile } from 'json-schema-to-typescript'; -import { toSafeString } from 'json-schema-to-typescript/dist/src/utils'; -import { JSONSchema4, JSONSchema4Type } from 'json-schema'; -import traverse from 'json-schema-traverse'; -import mkdirp from 'mkdirp'; import minimist from 'minimist'; -import { eraseRefObject, maybe, openApiMethods, parseToOpenApi, resolveRef, resolveRefArray } from './utils'; +import { maybe } from './utils'; +import generate from './index'; +// eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../package.json'); -function replaceRefsWithTsTypes(tree: JSONSchema4, prefix: string, rootNamespaceName: string): void { - traverse(tree, (node: JSONSchema4) => { - if (node.$ref) { - const ref = node.$ref; - delete node.$ref; - const nameWithOpenApiNamespace = ref.slice(prefix.length); - const nameParts = nameWithOpenApiNamespace - .split('.') - .map((part) => (isValidJsIdentifier(part) ? part : toSafeString(part))); - if (nameParts[0] !== rootNamespaceName) { - nameParts.unshift(rootNamespaceName); - } - node.tsType = nameParts.join('.'); - } - }); -} - -function sortFields(tree: JSONSchema4): void { - traverse(tree, (node: JSONSchema4) => { - if (node.type === 'object' && node.properties) { - const sortedProperties = {}; - const keys = sortBy(Object.keys(node.properties)); - for (const key of keys) { - sortedProperties[key] = node.properties[key]; - } - node.properties = sortedProperties; - } - }); -} - -function placeExplicitAdditionalProperties(tree: JSONSchema4, defaultValue: boolean): void { - traverse(tree, (node: JSONSchema4) => { - if (node.type === 'object' && !Object.prototype.hasOwnProperty.call(node, 'additionalProperties')) { - node.additionalProperties = defaultValue; - } - }); -} - -// @DOC: The library generates titled inline types as standalone interfaces which we do not want. -// @REFERENCE: https://github.com/bcherny/json-schema-to-typescript/issues/269 -// @REFERENCE: https://github.com/bcherny/json-schema-to-typescript/issues/181 -// @TODO: Maybe generate nested namespaces for titled inline types? -function trimTypeTitles(tree: JSONSchema4): void { - traverse(tree, (node: JSONSchema4) => { - if (Object.prototype.hasOwnProperty.call(node, 'title')) { - delete node.title; - } - }); -} - -// @TODO: Cover with tests -async function schemaToTsTypeExpression( - project: Project, - schema: JSONSchema4, - rootNamespaceName: string, - skipAdditionalProperties: boolean, - shouldSortFields: boolean, -): Promise { - // @NOTE: Wrap schema in object to force generator to produce type instead of interface - const wrappedSchema = { type: 'object', properties: { target: cloneDeep(schema) } } as JSONSchema4; - replaceRefsWithTsTypes(wrappedSchema, '#/components/schemas/', rootNamespaceName); - trimTypeTitles(wrappedSchema); - if (skipAdditionalProperties) { - placeExplicitAdditionalProperties(wrappedSchema, false); - } - if (shouldSortFields) { - sortFields(wrappedSchema); - } - - const rawTsWrappedInterface = await compile(wrappedSchema, 'Temp', { bannerComment: '' }); - - // @TODO: Use some different way to generate typescript types from json schema, - // because using temporary files is meh - const tempFile = project.createSourceFile('temp.ts', rawTsWrappedInterface); - const tsInterface = tempFile.getInterfaceOrThrow('Temp'); - const typeNode = tsInterface.getProperties()[0].getTypeNode(); - if (!typeNode) { - throw new Error('Unexpected situation, did not find type node'); - } - const tsTypeExpression = typeNode.getText(); - project.removeSourceFile(tempFile); - return tsTypeExpression; -} - -// @TODO: Cover with tests -async function schemaToTsTypeDeclaration( - project: Project, - schema: JSONSchema4, - path: string, - name: string, - rootNamespaceName: string, - skipAdditionalProperties: boolean, - namedEnums: boolean, - shouldSortFields: boolean, -): Promise { - replaceRefsWithTsTypes(schema, '#/components/schemas/', rootNamespaceName); - trimTypeTitles(schema); - if (skipAdditionalProperties) { - placeExplicitAdditionalProperties(schema, false); - } - if (shouldSortFields) { - sortFields(schema); - } - if (namedEnums) { - const enumValues = schema.enum; - if (enumValues) { - let tsEnumNames: string[] | null = null; - if (schema.tsEnumNames) { - const candidates = parseEnumNameCandidates(path, enumValues, 'tsEnumNames', schema.tsEnumNames); - delete schema.tsEnumNames; - if (candidates) tsEnumNames = candidates; - } - if (!tsEnumNames && schema['x-enumNames']) { - const candidates = parseEnumNameCandidates(path, enumValues, 'x-enumNames', schema['x-enumNames']); - if (candidates) tsEnumNames = candidates; - } - if (!tsEnumNames) { - const candidates = enumValues; - const noBadCandidates = candidates.every(isValidJsIdentifier); - if (noBadCandidates) { - tsEnumNames = candidates as string[]; - } else { - console.warn( - `Warning: Can not use values of ${path} as enum member names because some of them are not valid identifiers`, - ); - } - } - if (tsEnumNames) { - schema.tsEnumNames = tsEnumNames; - } else { - console.warn(`Warning: Enum ${path} will be generated as union type because no valid names are available`); - } - } - } - - const rawTsTypeDeclaration = await compile(schema, name, { bannerComment: '', enableConstEnums: false }); - - // @NOTE: json-schema-to-typescript forcibly converts type names to CamelCase, - // so we have to convert them back to original casing if possible - const generatedName = toSafeString(name); - const targetName = isValidJsIdentifier(name) ? name : generatedName; - let tsTypeDeclaration = rawTsTypeDeclaration; - if (targetName !== generatedName) { - const tempFile = project.createSourceFile('temp.ts', rawTsTypeDeclaration); - const tsInterface = tempFile.getInterface(generatedName); - if (tsInterface) { - tsInterface.rename(name); - } - const tsEnum = tempFile.getEnum(generatedName); - if (tsEnum) { - tsEnum.rename(name); - } - const tsTypeAlias = tempFile.getTypeAlias(generatedName); - if (tsTypeAlias) { - tsTypeAlias.rename(name); - } - - tsTypeDeclaration = tempFile.getText(); - project.removeSourceFile(tempFile); - } - - return tsTypeDeclaration; -} - -const VALID_IDENTIFIER_REGEX = /^[$_\p{L}][$_\p{L}\p{N}]*$/u; -function isValidJsIdentifier(name: unknown): boolean { - if (typeof name !== 'string') return false; - return VALID_IDENTIFIER_REGEX.test(name); -} - -function parseEnumNameCandidates( - path: string, - values: JSONSchema4Type[], - field: string, - candidates: unknown, -): string[] | null { - if (!candidates) return null; - if (!Array.isArray(candidates)) { - console.warn( - `Warning: Ignoring ${field} of ${path} because it does not look valid, should be a list of valid identifiers`, - ); - return null; - } - const hasBadCandidates = !candidates.every(isValidJsIdentifier); - if (hasBadCandidates) { - console.warn(`Warning: Ignoring ${field} of ${path} because some values are not valid identifiers`); - return null; - } - if (candidates.length !== values.length) { - console.warn( - `Warning: Ignoring ${field} of ${path} because number of names does not correspond to number of values`, - ); - return null; - } - return candidates as string[]; -} - async function main(): Promise { - // @SECTION: + // @SECTION: Args parse const args = process.argv.slice(2); type Argv = { out?: string; @@ -313,245 +109,18 @@ async function main(): Promise { console.error('You have to specify --export NAME`'); return 1; } - // - // @SECTION: Setup - const [openApiParser, openApiDocument] = await parseToOpenApi(inputPath, { validate }); - // - const project = new Project({ useInMemoryFileSystem: true }); - const generatedFile = project.createSourceFile(`generated.ts`); - const rootNamespace = generatedFile.addModule({ - declarationKind: ModuleDeclarationKind.Namespace, - name: exportName, - isExported: true, - }); - // - // @SECTION: Schemas - const components = openApiDocument.components; - if (components) { - const schemas = eraseRefObject(components.schemas); - if (schemas) { - for (const [nameWithOpenApiNamespace, schema] of Object.entries(schemas)) { - const nameParts = nameWithOpenApiNamespace.split('.'); - const namespaceNames = nameParts.slice(0, -1); - if (namespaceNames.length > 0 && namespaceNames[0] === exportName) { - namespaceNames.splice(0, 1); - } - const name = nameParts[nameParts.length - 1]; - const path = `#/components/schemas/${name}`; - let targetNamespace = rootNamespace; - for (const namespaceName of namespaceNames) { - const childNamespace = targetNamespace.getModule(namespaceName); - if (childNamespace) targetNamespace = childNamespace; - else - targetNamespace = targetNamespace.addModule({ - declarationKind: ModuleDeclarationKind.Namespace, - name: namespaceName, - isExported: true, - }); - } - - const jsonSchema = cloneDeep(schema) as JSONSchema4; - const tsTypeDeclaration = await schemaToTsTypeDeclaration( - project, - jsonSchema, - path, - name, - exportName, - skipAdditionalProperties, - namedEnums, - shouldSortFields, - ); - targetNamespace.addStatements((writer) => { - writer.write(tsTypeDeclaration); - }); - } - } - } // - // @SECTION: Routes - const apiInterface = generatedFile.addInterface({ - name: exportName, - isExported: true, - properties: [ - { - name: 'version', - type: (writer) => writer.quote('1'), - }, - ], + await generate({ + exportName, + inputPath, + outputPath, + skipValidate: !validate, + sortFields: shouldSortFields, + unionEnums: !namedEnums, + keepAdditionalProperties: !skipAdditionalProperties, }); - apiInterface.addProperty({ - name: 'routes', // NOTE: E.g. PetStore.Api['routes'] - type: Writers.objectType({ - properties: await Promise.all( - Object.entries(openApiDocument.paths).map(async ([route, pathItem]) => { - if (!pathItem) { - // @NOTE: This seems to be just a side effect of Object.entries with optional index signature, - // so it should never happen in practice - // @REFERENCE: https://github.com/kogosoftwarellc/open-api/pull/702 - throw new Error(`Unexpected situation, pathItem of ${route} is missing`); - } - const commonParameters = resolveRefArray(openApiParser, pathItem.parameters) || []; // @NOTE: Url fragment params - return { - name: JSON.stringify(route), // @NOTE: E.g. PetStore.Api['routes']['/users/{id}'] - type: Writers.objectType({ - properties: await Promise.all( - openApiMethods - .filter((method) => Object.prototype.hasOwnProperty.call(pathItem, method)) - .map(async (method) => { - const operationProperties: OptionalKind[] = []; - const operation = pathItem[method]!; // @ASSERT: Checked by filter above - const requestBody = resolveRef(openApiParser, operation.requestBody); - if (requestBody) { - const required = requestBody.required; - // @TODO: This is flaky, what if request body has multiple media types? - const jsonMediaType = maybe(requestBody.content['application/json']); - const formDataMediaType = maybe( - requestBody.content['multipart/form-data'] || - requestBody.content['application/x-www-form-urlencoded'], - ); - if (jsonMediaType) { - const schema = jsonMediaType.schema; - if (!schema) { - throw new Error(`Unexpected situation, schema for request body of ${route} is missing`); - } - const tsTypeExpression = await schemaToTsTypeExpression( - project, - schema, - exportName, - skipAdditionalProperties, - shouldSortFields, - ); - operationProperties.push({ name: 'body', type: tsTypeExpression, hasQuestionToken: !required }); - } else if (formDataMediaType) { - // @NOTE: Form data currently can not be typed further, so we ignore everything else - operationProperties.push({ name: 'body', type: 'FormData', hasQuestionToken: !required }); - } else { - console.warn(`Warning: Unknown media type for request body of ${route}`); - operationProperties.push({ name: 'body', type: 'unknown', hasQuestionToken: !required }); - } - } - // - const localParameters = resolveRefArray(openApiParser, operation.parameters) || []; // @NOTE: Url fragment params - const parameters = commonParameters.concat(localParameters); - const pathParameters = parameters.filter((parameter) => parameter.in === 'path'); - if (pathParameters.length > 0) { - const paramProperties: OptionalKind[] = []; - for (const parameter of pathParameters) { - const schema = parameter.schema; - if (!schema) { - throw new Error( - `Unexpected situation, schema for parameter ${parameter.name} of ${route} is missing`, - ); - } - const tsTypeExpression = await schemaToTsTypeExpression( - project, - schema, - exportName, - skipAdditionalProperties, - shouldSortFields, - ); - paramProperties.push({ - name: parameter.name, - type: tsTypeExpression, - hasQuestionToken: !parameter.required, - }); - } - operationProperties.push({ - name: 'params', - type: Writers.objectType({ properties: paramProperties }), - hasQuestionToken: pathParameters.every((parameter) => !parameter.required), - }); - } - // - const queryParameters = parameters.filter((parameter) => parameter.in === 'query'); - if (queryParameters.length > 0) { - const paramProperties: OptionalKind[] = []; - for (const parameter of queryParameters) { - const schema = parameter.schema; - if (!schema) { - throw new Error( - `Unexpected situation, schema for parameter ${parameter.name} of ${route} is missing`, - ); - } - const tsTypeExpression = await schemaToTsTypeExpression( - project, - schema, - exportName, - skipAdditionalProperties, - shouldSortFields, - ); - paramProperties.push({ - name: parameter.name, - type: tsTypeExpression, - hasQuestionToken: !parameter.required, - }); - } - operationProperties.push({ - name: 'query', - type: Writers.objectType({ properties: paramProperties }), - hasQuestionToken: queryParameters.every((parameter) => !parameter.required), - }); - } - // - // @TODO: Other parameters (like headers)? - // - const responses = operation.responses; - if (responses) { - // @TODO: This is flaky, what if response has multiple status codes? - const http200 = resolveRef(openApiParser, responses['200']); // @NOTE: OpenAPI types are wrong about this index type - if (http200) { - const mediaTypeObject = http200.content; - if (mediaTypeObject) { - // @TODO: This is flaky, what if response has multiple media types? - // @TODO: Add textual media types - const jsonMediaType = maybe(mediaTypeObject['application/json']); - if (jsonMediaType) { - const schema = jsonMediaType.schema; - if (!schema) { - throw new Error(`Unexpected situation, schema for response body of ${route} is missing`); - } - const tsTypeExpression = await schemaToTsTypeExpression( - project, - schema, - exportName, - skipAdditionalProperties, - shouldSortFields, - ); - operationProperties.push({ - name: 'response', - type: tsTypeExpression, - hasQuestionToken: false, - }); - } else { - operationProperties.push({ name: 'response', type: 'ArrayBuffer' }); - operationProperties.push({ - name: 'responseType', - type: (writer) => writer.quote('arraybuffer'), - }); - } - } - } - } - return { - name: method.toUpperCase(), // @NOTE: E.g. PetStore.Api['routes']['/users/{id}']['POST'] - type: Writers.objectType({ properties: operationProperties }), - }; - }), - ), - }), - }; - }), - ), - }), - }); - // - // @TODO: Programmatic prettier (can be hard to befriend with TypeScript files) - generatedFile.formatText(); - await generatedFile.save(); - const generatedCodeString = generatedFile.getFullText(); - await mkdirp(nodePath.dirname(outputPath)); - await outerFs.writeFile(outputPath, generatedCodeString); + // return 0; } diff --git a/packages/taxios-generate/src/index.ts b/packages/taxios-generate/src/index.ts new file mode 100644 index 0000000..8b9b215 --- /dev/null +++ b/packages/taxios-generate/src/index.ts @@ -0,0 +1,478 @@ +import { eraseRefObject, maybe, openApiMethods, parseToOpenApi, resolveRef, resolveRefArray } from './utils'; +import { ModuleDeclarationKind, OptionalKind, Project, PropertySignatureStructure, Writers } from 'ts-morph'; +import { cloneDeep, sortBy } from 'lodash'; +import { JSONSchema4, JSONSchema4Type } from 'json-schema'; +import mkdirp from 'mkdirp'; +import nodePath from 'path'; +import { promises as outerFs } from 'fs'; +import { compile } from 'json-schema-to-typescript'; +import { toSafeString } from 'json-schema-to-typescript/dist/src/utils'; +import traverse from 'json-schema-traverse'; + +function replaceRefsWithTsTypes(tree: JSONSchema4, prefix: string, rootNamespaceName: string): void { + traverse(tree, (node: JSONSchema4) => { + if (node.$ref) { + const ref = node.$ref; + delete node.$ref; + const nameWithOpenApiNamespace = ref.slice(prefix.length); + const nameParts = nameWithOpenApiNamespace + .split('.') + .map((part) => (isValidJsIdentifier(part) ? part : toSafeString(part))); + if (nameParts[0] !== rootNamespaceName) { + nameParts.unshift(rootNamespaceName); + } + node.tsType = nameParts.join('.'); + } + }); +} + +function sortFields(tree: JSONSchema4): void { + traverse(tree, (node: JSONSchema4) => { + if (node.type === 'object' && node.properties) { + const sortedProperties = {}; + const keys = sortBy(Object.keys(node.properties)); + for (const key of keys) { + sortedProperties[key] = node.properties[key]; + } + node.properties = sortedProperties; + } + }); +} + +function placeExplicitAdditionalProperties(tree: JSONSchema4, defaultValue: boolean): void { + traverse(tree, (node: JSONSchema4) => { + if (node.type === 'object' && !Object.prototype.hasOwnProperty.call(node, 'additionalProperties')) { + node.additionalProperties = defaultValue; + } + }); +} + +// @DOC: The library generates titled inline types as standalone interfaces which we do not want. +// @REFERENCE: https://github.com/bcherny/json-schema-to-typescript/issues/269 +// @REFERENCE: https://github.com/bcherny/json-schema-to-typescript/issues/181 +// @TODO: Maybe generate nested namespaces for titled inline types? +function trimTypeTitles(tree: JSONSchema4): void { + traverse(tree, (node: JSONSchema4) => { + if (Object.prototype.hasOwnProperty.call(node, 'title')) { + delete node.title; + } + }); +} + +// @TODO: Cover with tests +async function schemaToTsTypeExpression( + project: Project, + schema: JSONSchema4, + rootNamespaceName: string, + skipAdditionalProperties: boolean, + shouldSortFields: boolean, +): Promise { + // @NOTE: Wrap schema in object to force generator to produce type instead of interface + const wrappedSchema = { type: 'object', properties: { target: cloneDeep(schema) } } as JSONSchema4; + replaceRefsWithTsTypes(wrappedSchema, '#/components/schemas/', rootNamespaceName); + trimTypeTitles(wrappedSchema); + if (skipAdditionalProperties) { + placeExplicitAdditionalProperties(wrappedSchema, false); + } + if (shouldSortFields) { + sortFields(wrappedSchema); + } + + const rawTsWrappedInterface = await compile(wrappedSchema, 'Temp', { bannerComment: '' }); + + // @TODO: Use some different way to generate typescript types from json schema, + // because using temporary files is meh + const tempFile = project.createSourceFile('temp.ts', rawTsWrappedInterface); + const tsInterface = tempFile.getInterfaceOrThrow('Temp'); + const typeNode = tsInterface.getProperties()[0].getTypeNode(); + if (!typeNode) { + throw new Error('Unexpected situation, did not find type node'); + } + const tsTypeExpression = typeNode.getText(); + project.removeSourceFile(tempFile); + return tsTypeExpression; +} + +const VALID_IDENTIFIER_REGEX = /^[$_\p{L}][$_\p{L}\p{N}]*$/u; +function isValidJsIdentifier(name: unknown): boolean { + if (typeof name !== 'string') return false; + return VALID_IDENTIFIER_REGEX.test(name); +} + +function parseEnumNameCandidates( + path: string, + values: JSONSchema4Type[], + field: string, + candidates: unknown, +): string[] | null { + if (!candidates) return null; + if (!Array.isArray(candidates)) { + console.warn( + `Warning: Ignoring ${field} of ${path} because it does not look valid, should be a list of valid identifiers`, + ); + return null; + } + const hasBadCandidates = !candidates.every(isValidJsIdentifier); + if (hasBadCandidates) { + console.warn(`Warning: Ignoring ${field} of ${path} because some values are not valid identifiers`); + return null; + } + if (candidates.length !== values.length) { + console.warn( + `Warning: Ignoring ${field} of ${path} because number of names does not correspond to number of values`, + ); + return null; + } + return candidates as string[]; +} + +// @TODO: Cover with tests +async function schemaToTsTypeDeclaration( + project: Project, + schema: JSONSchema4, + path: string, + name: string, + rootNamespaceName: string, + skipAdditionalProperties: boolean, + namedEnums: boolean, + shouldSortFields: boolean, +): Promise { + replaceRefsWithTsTypes(schema, '#/components/schemas/', rootNamespaceName); + trimTypeTitles(schema); + if (skipAdditionalProperties) { + placeExplicitAdditionalProperties(schema, false); + } + if (shouldSortFields) { + sortFields(schema); + } + if (namedEnums) { + const enumValues = schema.enum; + if (enumValues) { + let tsEnumNames: string[] | null = null; + if (schema.tsEnumNames) { + const candidates = parseEnumNameCandidates(path, enumValues, 'tsEnumNames', schema.tsEnumNames); + delete schema.tsEnumNames; + if (candidates) tsEnumNames = candidates; + } + if (!tsEnumNames && schema['x-enumNames']) { + const candidates = parseEnumNameCandidates(path, enumValues, 'x-enumNames', schema['x-enumNames']); + if (candidates) tsEnumNames = candidates; + } + if (!tsEnumNames) { + const candidates = enumValues; + const noBadCandidates = candidates.every(isValidJsIdentifier); + if (noBadCandidates) { + tsEnumNames = candidates as string[]; + } else { + console.warn( + `Warning: Can not use values of ${path} as enum member names because some of them are not valid identifiers`, + ); + } + } + if (tsEnumNames) { + schema.tsEnumNames = tsEnumNames; + } else { + console.warn(`Warning: Enum ${path} will be generated as union type because no valid names are available`); + } + } + } + + const rawTsTypeDeclaration = await compile(schema, name, { bannerComment: '', enableConstEnums: false }); + + // @NOTE: json-schema-to-typescript forcibly converts type names to CamelCase, + // so we have to convert them back to original casing if possible + const generatedName = toSafeString(name); + const targetName = isValidJsIdentifier(name) ? name : generatedName; + let tsTypeDeclaration = rawTsTypeDeclaration; + if (targetName !== generatedName) { + const tempFile = project.createSourceFile('temp.ts', rawTsTypeDeclaration); + const tsInterface = tempFile.getInterface(generatedName); + if (tsInterface) { + tsInterface.rename(name); + } + const tsEnum = tempFile.getEnum(generatedName); + if (tsEnum) { + tsEnum.rename(name); + } + const tsTypeAlias = tempFile.getTypeAlias(generatedName); + if (tsTypeAlias) { + tsTypeAlias.rename(name); + } + + tsTypeDeclaration = tempFile.getText(); + project.removeSourceFile(tempFile); + } + + return tsTypeDeclaration; +} + +interface GenerateProps { + exportName: string; + inputPath: string; + outputPath?: string; + skipValidate?: boolean; + sortFields?: boolean; + unionEnums?: boolean; + keepAdditionalProperties?: boolean; +} + +async function generate({ + exportName, + inputPath, + outputPath, + skipValidate = false, + sortFields = false, + unionEnums = false, + keepAdditionalProperties = false, +}: GenerateProps) { + const validate = !skipValidate; + const namedEnums = !unionEnums; + const skipAdditionalProperties = !keepAdditionalProperties; + // + // @SECTION: Setup + const [openApiParser, openApiDocument] = await parseToOpenApi(inputPath, { validate }); + // + const project = new Project({ useInMemoryFileSystem: true }); + const generatedFile = project.createSourceFile(`generated.ts`); + const rootNamespace = generatedFile.addModule({ + declarationKind: ModuleDeclarationKind.Namespace, + name: exportName, + isExported: true, + }); + // + // @SECTION: Schemas + const components = openApiDocument.components; + if (components) { + const schemas = eraseRefObject(components.schemas); + if (schemas) { + for (const [nameWithOpenApiNamespace, schema] of Object.entries(schemas)) { + const nameParts = nameWithOpenApiNamespace.split('.'); + const namespaceNames = nameParts.slice(0, -1); + if (namespaceNames.length > 0 && namespaceNames[0] === exportName) { + namespaceNames.splice(0, 1); + } + const name = nameParts[nameParts.length - 1]; + const path = `#/components/schemas/${name}`; + + let targetNamespace = rootNamespace; + for (const namespaceName of namespaceNames) { + const childNamespace = targetNamespace.getModule(namespaceName); + if (childNamespace) targetNamespace = childNamespace; + else + targetNamespace = targetNamespace.addModule({ + declarationKind: ModuleDeclarationKind.Namespace, + name: namespaceName, + isExported: true, + }); + } + + const jsonSchema = cloneDeep(schema) as JSONSchema4; + const tsTypeDeclaration = await schemaToTsTypeDeclaration( + project, + jsonSchema, + path, + name, + exportName, + skipAdditionalProperties, + namedEnums, + sortFields, + ); + targetNamespace.addStatements((writer) => { + writer.write(tsTypeDeclaration); + }); + } + } + } + // + // @SECTION: Routes + const apiInterface = generatedFile.addInterface({ + name: exportName, + isExported: true, + properties: [ + { + name: 'version', + type: (writer) => writer.quote('1'), + }, + ], + }); + apiInterface.addProperty({ + name: 'routes', // NOTE: E.g. PetStore.Api['routes'] + type: Writers.objectType({ + properties: await Promise.all( + Object.entries(openApiDocument.paths).map(async ([route, pathItem]) => { + if (!pathItem) { + // @NOTE: This seems to be just a side effect of Object.entries with optional index signature, + // so it should never happen in practice + // @REFERENCE: https://github.com/kogosoftwarellc/open-api/pull/702 + throw new Error(`Unexpected situation, pathItem of ${route} is missing`); + } + const commonParameters = resolveRefArray(openApiParser, pathItem.parameters) || []; // @NOTE: Url fragment params + return { + name: JSON.stringify(route), // @NOTE: E.g. PetStore.Api['routes']['/users/{id}'] + type: Writers.objectType({ + properties: await Promise.all( + openApiMethods + .filter((method) => Object.prototype.hasOwnProperty.call(pathItem, method)) + .map(async (method) => { + const operationProperties: OptionalKind[] = []; + const operation = pathItem[method]!; // @ASSERT: Checked by filter above + const requestBody = resolveRef(openApiParser, operation.requestBody); + if (requestBody) { + const required = requestBody.required; + // @TODO: This is flaky, what if request body has multiple media types? + const jsonMediaType = maybe(requestBody.content['application/json']); + const formDataMediaType = maybe( + requestBody.content['multipart/form-data'] || + requestBody.content['application/x-www-form-urlencoded'], + ); + if (jsonMediaType) { + const schema = jsonMediaType.schema; + if (!schema) { + throw new Error(`Unexpected situation, schema for request body of ${route} is missing`); + } + const tsTypeExpression = await schemaToTsTypeExpression( + project, + schema, + exportName, + skipAdditionalProperties, + sortFields, + ); + operationProperties.push({ name: 'body', type: tsTypeExpression, hasQuestionToken: !required }); + } else if (formDataMediaType) { + // @NOTE: Form data currently can not be typed further, so we ignore everything else + operationProperties.push({ name: 'body', type: 'FormData', hasQuestionToken: !required }); + } else { + console.warn(`Warning: Unknown media type for request body of ${route}`); + operationProperties.push({ name: 'body', type: 'unknown', hasQuestionToken: !required }); + } + } + // + const localParameters = resolveRefArray(openApiParser, operation.parameters) || []; // @NOTE: Url fragment params + const parameters = commonParameters.concat(localParameters); + const pathParameters = parameters.filter((parameter) => parameter.in === 'path'); + if (pathParameters.length > 0) { + const paramProperties: OptionalKind[] = []; + for (const parameter of pathParameters) { + const schema = parameter.schema; + if (!schema) { + throw new Error( + `Unexpected situation, schema for parameter ${parameter.name} of ${route} is missing`, + ); + } + const tsTypeExpression = await schemaToTsTypeExpression( + project, + schema, + exportName, + skipAdditionalProperties, + sortFields, + ); + paramProperties.push({ + name: parameter.name, + type: tsTypeExpression, + hasQuestionToken: !parameter.required, + }); + } + operationProperties.push({ + name: 'params', + type: Writers.objectType({ properties: paramProperties }), + hasQuestionToken: pathParameters.every((parameter) => !parameter.required), + }); + } + // + const queryParameters = parameters.filter((parameter) => parameter.in === 'query'); + if (queryParameters.length > 0) { + const paramProperties: OptionalKind[] = []; + for (const parameter of queryParameters) { + const schema = parameter.schema; + if (!schema) { + throw new Error( + `Unexpected situation, schema for parameter ${parameter.name} of ${route} is missing`, + ); + } + const tsTypeExpression = await schemaToTsTypeExpression( + project, + schema, + exportName, + skipAdditionalProperties, + sortFields, + ); + paramProperties.push({ + name: parameter.name, + type: tsTypeExpression, + hasQuestionToken: !parameter.required, + }); + } + operationProperties.push({ + name: 'query', + type: Writers.objectType({ properties: paramProperties }), + hasQuestionToken: queryParameters.every((parameter) => !parameter.required), + }); + } + // + // @TODO: Other parameters (like headers)? + // + const responses = operation.responses; + if (responses) { + // @TODO: This is flaky, what if response has multiple status codes? + const http200 = resolveRef(openApiParser, responses['200']); // @NOTE: OpenAPI types are wrong about this index type + if (http200) { + const mediaTypeObject = http200.content; + if (mediaTypeObject) { + // @TODO: This is flaky, what if response has multiple media types? + // @TODO: Add textual media types + const jsonMediaType = maybe(mediaTypeObject['application/json']); + if (jsonMediaType) { + const schema = jsonMediaType.schema; + if (!schema) { + throw new Error(`Unexpected situation, schema for response body of ${route} is missing`); + } + const tsTypeExpression = await schemaToTsTypeExpression( + project, + schema, + exportName, + skipAdditionalProperties, + sortFields, + ); + operationProperties.push({ + name: 'response', + type: tsTypeExpression, + hasQuestionToken: false, + }); + } else { + operationProperties.push({ name: 'response', type: 'ArrayBuffer' }); + operationProperties.push({ + name: 'responseType', + type: (writer) => writer.quote('arraybuffer'), + }); + } + } + } + } + return { + name: method.toUpperCase(), // @NOTE: E.g. PetStore.Api['routes']['/users/{id}']['POST'] + type: Writers.objectType({ properties: operationProperties }), + }; + }), + ), + }), + }; + }), + ), + }), + }); + // + // @TODO: Programmatic prettier (can be hard to befriend with TypeScript files) + generatedFile.formatText(); + await generatedFile.save(); + const generatedCodeString = generatedFile.getFullText(); + // + if (outputPath) { + await mkdirp(nodePath.dirname(outputPath)); + await outerFs.writeFile(outputPath, generatedCodeString); + } + // + return generatedCodeString; +} + +export { generate }; +export default generate; From 733ef7e9d61e832bac55ad21adfd342fbd8216ef Mon Sep 17 00:00:00 2001 From: crutch12 Date: Fri, 18 Feb 2022 01:25:49 +0300 Subject: [PATCH 2/3] feat: programmatic usage README --- packages/taxios-generate/README.md | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/taxios-generate/README.md b/packages/taxios-generate/README.md index ebc59bf..43a906f 100644 --- a/packages/taxios-generate/README.md +++ b/packages/taxios-generate/README.md @@ -4,6 +4,8 @@ ## Generate +### CLI + ```sh taxios-generate [options] ``` @@ -18,14 +20,45 @@ Options: --sort-fields [0.2.10+] Sort fields in interfaces instead of keeping the order from source ``` +### As module (programmatically) + +```typescript +interface GenerateProps { + exportName: string; + inputPath: string; + outputPath?: string; + skipValidate?: boolean; + sortFields?: boolean; + unionEnums?: boolean; + keepAdditionalProperties?: boolean; +} + +export function generate(options: GenerateProps): Promise; +``` + ## Example Swagger: https://petstore.swagger.io/ +### CLI + ``` taxios-generate -o PetStore.ts -e PetStore https://petstore.swagger.io/v2/swagger.json ``` +### As module (programmatically) + +```javascript +import { generate } from '@simplesmiler/taxios-generate'; +// import taxiosGenerate from '@simplesmiler/taxios-generate'; + +const result = await generate({ + exportName: 'PetStore', + inputPath: 'https://petstore.swagger.io/v2/swagger.json', + outputPath: path.resolve('./PetStore.ts'), // optional +}); +``` + Result: [PetStore.ts](https://github.com/simplesmiler/taxios/blob/master/packages/taxios-sandbox/src/generated/PetStore.ts) ## Use From 1c2721f66ee82ffcaa30a5f995c585e9a1994922 Mon Sep 17 00:00:00 2001 From: crutch12 Date: Wed, 9 Feb 2022 20:48:12 +0300 Subject: [PATCH 3/3] test(taxios-generate): testing programmatic usage with jest --- .eslintrc.js | 3 + packages/taxios-generate/.npmignore | 2 + packages/taxios-generate/index.test.js | 75 +++++++++++++++++++++++++ packages/taxios-generate/jest.config.js | 4 ++ packages/taxios-generate/package.json | 4 +- 5 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/taxios-generate/index.test.js create mode 100644 packages/taxios-generate/jest.config.js diff --git a/.eslintrc.js b/.eslintrc.js index b470b99..17c022d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,8 @@ module.exports = { root: true, + env: { + jest: true, + }, parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], diff --git a/packages/taxios-generate/.npmignore b/packages/taxios-generate/.npmignore index a034019..49e94f5 100644 --- a/packages/taxios-generate/.npmignore +++ b/packages/taxios-generate/.npmignore @@ -1,3 +1,5 @@ # Source files /src /tsconfig.build.json +/index.test.js +/jest.config.js diff --git a/packages/taxios-generate/index.test.js b/packages/taxios-generate/index.test.js new file mode 100644 index 0000000..d9b3db9 --- /dev/null +++ b/packages/taxios-generate/index.test.js @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const outerFs = require('fs').promises; +const path = require('path'); +const taxiosGenerate = require('./dist/index').default; + +const generatedDir = path.resolve('./generated'); + +const removeGeneratedDir = () => { + return outerFs + .stat(generatedDir) // exists + .catch(() => { + return null; + }) + .then((stats) => { + if (!stats) return; + return outerFs.rmdir(generatedDir, { recursive: true }); + }); +}; + +beforeAll(removeGeneratedDir); +afterAll(removeGeneratedDir); + +describe('Taxios Generate (without CLI)', () => { + describe('Generate without output path', () => { + test('When minimal config passed and inputPath is external link, then result contains "PetstoreAPI"', async () => { + const result = await taxiosGenerate({ + exportName: 'PetstoreAPI', + inputPath: 'https://petstore.swagger.io/v2/swagger.json', + }); + + expect(result).toContain('PetstoreAPI'); + }); + + test('When full config passed and inputPath is external link, then result contains "PetstoreAPI"', async () => { + const result = await taxiosGenerate({ + exportName: 'PetstoreAPI', + inputPath: 'https://petstore.swagger.io/v2/swagger.json', + skipValidate: true, + sortFields: true, + unionEnums: true, + keepAdditionalProperties: true, + }); + + expect(result).toContain('PetstoreAPI'); + }); + }); + + describe('Generate with output path', () => { + const outputPath = path.resolve(generatedDir, './PetstoreAPI.ts'); + + test(`When minimal config passed and inputPath is external link, then result emits in ${outputPath} file`, async () => { + await taxiosGenerate({ + exportName: 'PetstoreAPI', + inputPath: 'https://petstore.swagger.io/v2/swagger.json', + outputPath, + }); + + const stats = await outerFs.stat(outputPath); + + expect(stats).not.toBeNull(); + }); + + test(`When minimal config passed and inputPath is external link, then emitted result in ${outputPath} file contains "PetstoreAPI"`, async () => { + await taxiosGenerate({ + exportName: 'PetstoreAPI', + inputPath: 'https://petstore.swagger.io/v2/swagger.json', + outputPath, + }); + + const result = await outerFs.readFile(outputPath, { encoding: 'utf8' }); + + expect(result).toContain('PetstoreAPI'); + }); + }); +}); diff --git a/packages/taxios-generate/jest.config.js b/packages/taxios-generate/jest.config.js new file mode 100644 index 0000000..82c92ed --- /dev/null +++ b/packages/taxios-generate/jest.config.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line no-undef +module.exports = { + name: 'taxios-generate', +}; diff --git a/packages/taxios-generate/package.json b/packages/taxios-generate/package.json index 5b2f75b..cb7e437 100644 --- a/packages/taxios-generate/package.json +++ b/packages/taxios-generate/package.json @@ -24,7 +24,8 @@ "scripts": { "build": "npm run clean && npm run compile", "clean": "shx rm -rf ./dist", - "compile": "tsc -p tsconfig.build.json" + "compile": "tsc -p tsconfig.build.json", + "test": "jest" }, "dependencies": { "@apidevtools/swagger-parser": "^10.0.3", @@ -41,6 +42,7 @@ }, "devDependencies": { "@types/node": "^16.7.1", + "jest": "^27.5.1", "shx": "^0.3.3", "typescript": "^4.3.5" },