diff --git a/README.md b/README.md index 226b6ec..9d4612b 100644 --- a/README.md +++ b/README.md @@ -59,4 +59,35 @@ yarn ts-openapi-gen generate > Рекомендуется использовать типы как есть, и всегда выставлять в > `override_policies` для `types` значение `override`. Однако хуки > могут являться частью бизнес-логики, или требовать вспомогательных -> действий. Поэтому считайте, что фича с генерацией хуков предназначена для упрощения создания нового раздела, но не для полной автоматизации. +> действий. Поэтому считайте, что фича с генерацией хуков предназначена для упрощения создания нового раздела, но не для полной автоматизации. + +# Example v2.0.0-beta + +const generator = + new OpenApiGenerator( + new URLRequest('https://example.com/index.yaml') + ); + +export default generaotr; + +or + +const generator = + new OpenApiGenerator( + new FileRequest('/mnt/d/users/john/index.yaml') + ); + +export default generator; + + + + + +# Middlewares + +[load.document.before] - takes a URL/path to document as argument, expects parsed object as output +[load.document.after] - takes parsed object as argument, expects modified object as output + +# Events + +[load.progress] - takes a percentage of progress as argument, expects void. For printing or formatting. diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..8ff2e0f Binary files /dev/null and b/bun.lockb differ diff --git a/package.json b/package.json index be27188..32769eb 100644 --- a/package.json +++ b/package.json @@ -45,55 +45,56 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "^10.1.0", - "@inquirer/input": "^1.0.7", - "@inquirer/prompts": "^1.1.3", - "@oclif/core": "^2", - "@oclif/plugin-help": "^5", + "@inquirer/input": "^1.2.16", + "@inquirer/prompts": "^1.2.3", + "@oclif/core": "^2.16.0", + "@oclif/plugin-help": "^5.2.20", "@oclif/plugin-plugins": "^2.4.7", - "@openapi-contrib/openapi-schema-to-json-schema": "^4.0.4", - "@openapitools/openapi-generator-cli": "^2.5.2", - "@stoplight/json-schema-ref-parser": "^9.2.4", - "@stoplight/yaml": "^4.2.3", + "@openapi-contrib/openapi-schema-to-json-schema": "^4.0.5", + "@openapitools/openapi-generator-cli": "^2.13.9", + "@stoplight/json-schema-ref-parser": "^9.2.7", + "@stoplight/yaml": "^4.3.0", "add": "^2.0.6", "case": "^1.6.3", "code-block-writer": "^12.0.0", - "fastify": "^4.12.0", + "fastify": "^4.28.1", "js-yaml": "^4.1.0", - "jsdom": "^22.0.0", + "jsdom": "^22.1.0", "json-schema": "^0.4.0", "json-schema-resolver": "^2.0.0", "json-schema-to-typescript": "^12.0.0", "kleur": "^4.1.5", "lodash.keyby": "^4.6.0", - "node-fetch-commonjs": "^3.2.4", - "openapi-typescript": "^6.1.0", - "p-queue-compat": "^1.0.223", + "middleware-io": "^2.8.1", + "node-fetch-commonjs": "^3.3.2", + "openapi-typescript": "^6.7.6", + "p-queue-compat": "^1.0.226", "prettier": "^2.8.8", "ts-morph": "^18.0.0", - "yaml": "^2.1.1", - "yargs": "^17.6.2", - "yarn": "^1.22.19" + "yaml": "^2.5.1", + "yargs": "^17.7.2", + "yarn": "^1.22.22" }, "devDependencies": { - "@trivago/prettier-plugin-sort-imports": "^4.1.1", - "@types/d3": "^7.4.0", - "@types/js-yaml": "^4.0.5", - "@types/jsdom": "^21.1.1", - "@types/lodash.keyby": "^4.6.7", - "@types/node": "^18.0.0", - "@types/yargs": "^17.0.22", - "@typescript-eslint/eslint-plugin": "^5.59.6", - "@typescript-eslint/parser": "^5.59.6", - "eslint": "^8.40.0", + "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/d3": "^7.4.3", + "@types/js-yaml": "^4.0.9", + "@types/jsdom": "^21.1.7", + "@types/lodash.keyby": "^4.6.9", + "@types/node": "^18.19.50", + "@types/yargs": "^17.0.33", + "@typescript-eslint/eslint-plugin": "^5.62.0", + "@typescript-eslint/parser": "^5.62.0", + "eslint": "^8.57.1", "eslint-config-airbnb": "^19.0.4", "eslint-config-oclif": "^4.0.0", "eslint-config-oclif-typescript": "^1.0.3", - "eslint-config-prettier": "^8.8.0", + "eslint-config-prettier": "^8.10.0", "eslint-plugin-unicorn": "^47.0.0", - "oclif": "^3", - "openapi-types": "^12.1.0", - "ts-node": "^10.9.1", - "tslib": "^2.5.0", - "typescript": "^5.0.4" + "oclif": "^3.17.2", + "openapi-types": "^12.1.3", + "ts-node": "^10.9.2", + "tslib": "^2.7.0", + "typescript": "^5.6.2" } -} +} \ No newline at end of file diff --git a/src/codeGen/ReactQueryHookGenerator.ts b/src/codeGen/ReactQueryHookGenerator.ts index ecf8c37..6c47125 100644 --- a/src/codeGen/ReactQueryHookGenerator.ts +++ b/src/codeGen/ReactQueryHookGenerator.ts @@ -35,15 +35,18 @@ export class ReactQueryHookGenerator { this.typeFetcher = typeFetcher; } - async checkFilePathForGroup(group: string) { - const folder = join(this.config.output_path, group); + async mkdirs(relativePath: string) { + const folder = join(this.config.output_path, relativePath); await mkdir(folder, { recursive: true }); const filePath = `${folder}/index.ts`; const isExisting = existsSync(filePath); if (this.overridePolicy === 'skip' && isExisting) { - console.log(kleur.italic(kleur.bgWhite(kleur.black(group))), 'хуки react-query пропущены как существующие'); + console.log( + kleur.italic(kleur.bgWhite(kleur.black(relativePath))), + 'хуки react-query пропущены как существующие' + ); return null; } @@ -291,8 +294,20 @@ export class ReactQueryHookGenerator { }); } - async generate(group: string, flatOperations: AugmentedOperation[]) { - const filePath = await this.checkFilePathForGroup(group); + async generate(operations: AugmentedOperation[]) { + const splitted: Record = {}; + + for (const operation of operations) { + if (!splitted[operation.storePath]) splitted[operation.storePath] = []; + splitted[operation.storePath].push(operation); + } + + const keys = Object.keys(splitted); + await Promise.all(keys.map(path => this.generateSingle(path, splitted[path]))); + } + + private async generateSingle(relativePath: string, operations: AugmentedOperation[]) { + const filePath = await this.mkdirs(relativePath); if (!filePath) return; const project = new Project(); @@ -306,35 +321,53 @@ export class ReactQueryHookGenerator { imports.push(...this.config['react-query'].imports); - this.queryKeysSchema = generateQueryKeysSchema(flatOperations); + this.queryKeysSchema = generateQueryKeysSchema(operations); generateQueryKeysConstant(this.queryKeysSchema, sourceFile); - for (const operation of flatOperations) { + const enumsPath = join(this.config.output_path, relativePath, 'enums.ts'); + const hasEnums = existsSync(enumsPath); + + if (hasEnums) { + sourceFile.addExportDeclaration({ + moduleSpecifier: './enums', + }); + } + + sourceFile.addExportDeclaration({ + moduleSpecifier: './types', + }); + + for (const operation of operations) { const queryParams = operation.queryParams; if (queryParams.length > 0 && operation.isMutation) { - console.error('Mutations with queryParams are not supported yet: check operation', operation.path); + console.error( + 'Mutations with queryParams are not supported yet: check operation', + operation.originalPath + ); continue; } const types = this.typeFetcher(operation); // eslint-disable-next-line unicorn/consistent-function-scoping - const resolveImport = (path: string) => { - const fullPath = join(this.config.output_path, path).replaceAll('\\', '/'); + const resolveImport = (importPath: string) => { + const fullPath = join(this.config.output_path, importPath).replaceAll('\\', '/'); const filePathNorm = filePath.replaceAll('\\', '/'); - let relativePath = relative(dirname(filePathNorm), fullPath).replaceAll('\\', '/'); + let relativeImportPath = relative(dirname(filePathNorm), fullPath).replaceAll('\\', '/'); - if (relativePath[0] !== '.') { - relativePath = './' + relativePath; + if (relativeImportPath[0] !== '.') { + relativeImportPath = './' + relativeImportPath; } - return relativePath; + return relativeImportPath; }; if (!types.response) { - console.error('No response found in operation ' + operation.path + ', of group ' + operation.group); + console.error( + 'No response found in operation ' + operation.originalPath + ', of group ' + operation.group + ); continue; } diff --git a/src/codeGen/queryKeys.ts b/src/codeGen/queryKeys.ts index 57bf39a..09954a9 100644 --- a/src/codeGen/queryKeys.ts +++ b/src/codeGen/queryKeys.ts @@ -10,8 +10,8 @@ export type QueryKeysSchema = { items: Record; }; -export function generateQueryKeysSchema(flatOperations: AugmentedOperation[]): QueryKeysSchema { - const searchOperations = flatOperations.filter(e => SEARCH_OPCODES.includes(parseOpcode(e))); +export function generateQueryKeysSchema(operations: AugmentedOperation[]): QueryKeysSchema { + const searchOperations = operations.filter(e => SEARCH_OPCODES.includes(parseOpcode(e))); const items = searchOperations.reduce((acc, searchOperation) => { const pathParameters = @@ -93,7 +93,7 @@ export function createCallQueryKey(operation: AugmentedOperation, type: 'query' const keyParts = operation.pathVariables.map(e => `${keyPrefix}${e}`).join(','); // Always invalidate search of multiple entities. - if (type === 'mutation' && operation.path.endsWith(':search')) + if (type === 'mutation' && operation.originalPath.endsWith(':search')) return `QueryKeys.${operation.original.operationId}()`; if (operation.original.requestBody && isHavePathParams) { diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index 9f8f697..e319afb 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -2,10 +2,11 @@ import input from '@inquirer/input'; import { checkbox, select } from '@inquirer/prompts'; import { Args, Command } from '@oclif/core'; +import { writeFile } from 'fs/promises'; import { OpenAPIV3 } from 'openapi-types'; import { ReactQueryHookGenerator } from '../../codeGen/ReactQueryHookGenerator'; -import { ParsedSchema, SchemaParser } from '../../common/SchemaParser'; +import { SchemaParser } from '../../common/SchemaParser'; import { runEslintAutoFix } from '../../common/helpers'; import { OverridePolicy } from '../../common/types'; import { Config, ConfigSchema, Target } from '../../config/Config'; @@ -29,7 +30,6 @@ export default class Generate extends Command { private conf!: ConfigSchema; private isSomePrompted = false; - private parsedSchema!: ParsedSchema; private async applyArgsToConfig() { const { args } = await this.parse(Generate); @@ -140,8 +140,11 @@ export default class Generate extends Command { await traverseAndModify( indexDocument, async ref => { + // TODO: apply load.document.before middlewares const result = await loader.loadJson(ref.absolutePath); + // TODO: apply load.document.after, return result + if (!ref.target) return result; if (result.components && ref.target.startsWith('components')) { @@ -169,13 +172,11 @@ export default class Generate extends Command { ); const schemaParser = new SchemaParser(this.conf, indexDocument); - this.parsedSchema = await schemaParser.parse(); - - const { groups, derefedPathGroupedOps } = this.parsedSchema; + const parsedSchema = await schemaParser.parse(); const typeRenderer = new TypeRenderer({ overridePolicy: this.conf.override_policies[Target.TYPES]!, - parsedSchema: this.parsedSchema, + parsedSchema: parsedSchema, config: this.conf, }); @@ -190,18 +191,14 @@ export default class Generate extends Command { config: this.conf, overridePolicy: override_policies[Target.REACT_QUERY]!, typeFetcher: operation => { - const types = typeRenderer.getTypesForRequest(operation.path, operation.method as any)!; + const types = typeRenderer.getTypesForRequest(operation.originalPath, operation.method as any)!; return types; }, }); console.log('⏳ Генерируем хуки react-query...'); - await Promise.all( - groups.map(group => { - return hookGen.generate(group, derefedPathGroupedOps[group]); - }) - ); + await hookGen.generate(parsedSchema.operations); console.log('✔️ Хуки сгенерированы!'); } diff --git a/src/common/SchemaParser.ts b/src/common/SchemaParser.ts index af82d4c..7d1f409 100644 --- a/src/common/SchemaParser.ts +++ b/src/common/SchemaParser.ts @@ -1,52 +1,22 @@ -import { pascal } from 'case'; import type { OpenAPIV3 } from 'openapi-types'; import { ConfigSchema } from '../config/Config'; -import { augmentPathsOperations, groupOperations } from './helpers'; +import { augmentPathsOperations } from './helpers'; import { AugmentedOperation } from './types'; export type ParsedSchema = { document: OpenAPIV3.Document; operations: AugmentedOperation[]; - derefedPathGroupedOps: Record; - groups: string[]; }; -const transformPath = (path: string) => pascal(path.replace('.yaml', '').replaceAll('/', '_').toLowerCase()); - -function parsePath(p: string) { - let preparedPath = p.replaceAll('../', '').replaceAll('./', ''); - - if (preparedPath.startsWith('#/')) { - preparedPath = `common/${preparedPath.slice(2)}`; - } - - if (preparedPath.includes('#/')) { - const [path, obj] = preparedPath.split('#/'); - - return [transformPath(path), obj, p] as const; - } - - if (preparedPath.endsWith('.yaml')) { - const [objYaml, ...rest] = preparedPath.split('/').reverse(); - const obj = objYaml.replace('.yaml', ''); - const path = rest.join('/'); - - return [transformPath(path), obj, p] as const; - } - - if (preparedPath.startsWith('common/')) { - const obj = preparedPath.split('common/')[1]; - - return ['common', obj, p] as const; - } - - return []; +export interface ParsedPath { + relativePath: string; + name: string; + originalPath: string; } export class SchemaParser { private schemaObject!: OpenAPIV3.Document; - private readonly uniqueRefs = new Set(); private config: ConfigSchema; constructor(config: ConfigSchema, schemaObject: OpenAPIV3.Document) { @@ -55,34 +25,84 @@ export class SchemaParser { } async parse(): Promise { - const grouped = new Map(); const paths = Object.keys(this.schemaObject.paths); - const parsedPaths = paths.map(element => parsePath(element)); - - for (const [path, name, originalPath] of parsedPaths) { - if (!path) continue; - - if (!grouped.get(path)) { - grouped.set(path, []); - } - - grouped.get(path)!.push({ - name: pascal(name), - originalPath, - }); - } - if (!paths) throw new Error('[SchemaParser] No paths found in openapi schema.'); - const operations = augmentPathsOperations(this.schemaObject.paths, this.config); - const derefedPathGroupedOps = groupOperations(operations); - const groups = Object.keys(derefedPathGroupedOps); + const operations = augmentPathsOperations(this.schemaObject.paths, this.config, undefined, { + '/common/files/download-protected': '/common', + '/cms/banner*': '/cms/banners', + '/cms/nameplates/nameplate-products': '/cms/nameplates', + '/cms/seo/template*': '/cms/seo/templates', + '/auth/login': '/auth', + '/auth/logout': '/auth', + '/auth/refresh': '/auth', + '/auth/current-user': '/auth', + '/logistic/point-enum-values': '/logistic/points', + '/logistic/delivery-service*': '/logistic/delivery-services', + '/logistic/federal-district-enum-values': '/logistic/federal-districts', + '/logistic/region-enum-values': '/logistic/regions', + '/logistic/cargo-orders': '/logistic/cargo', + '/logistic/shipment-methods': '/logistic/shipments', + '/orders/order-statuses': '/orders/orders', + '/orders/order-sources': '/orders/orders', + '/orders/payment-methods': '/orders/payments', + '/orders/payment-statuses': '/orders/payments', + '/orders/delivery-statuses': '/orders/deliveries', + '/orders/shipment-statuses': '/orders/shipments', + '/orders/refund-statuses': '/orders/refunds', + '/orders/refund-reasons': '/orders/refunds', + '/catalog/brand-enum-values': '/catalog/brands', + '/catalog/category-properties': '/catalog/categories', + '/catalog/category-enum-values': '/catalog/categories', + '/catalog/properties/properties-types': '/catalog/properties', + '/catalog/properties/directory': '/catalog/properties', + '/catalog/products*': '/catalog/products', + '/catalog/product-statuses': '/catalog/products', + '/catalog/product-import-warnings': '/catalog/product-imports', + '/catalog/product-status-enum-values': '/catalog/products', + '/catalog/product-status-types': '/catalog/products', + '/catalog/product-events': '/catalog/products', + '/catalog/product-event-operations': '/catalog/products', + '/catalog/reviews/review-statuses': '/catalog/reviews', + '/catalog/reviews/review-customer': '/catalog/reviews', + '/catalog/reviews/review-product': '/catalog/reviews', + '/catalog/feed-types': '/catalog/feeds', + '/catalog/feed-platforms': '/catalog/feeds', + '/catalog/feed-settings': '/catalog/feeds', + '/catalog/cloud-integrations': '', + '/customers/customer-enum-values': '/customers/customers', + '/customers/users': '/customers/customers', + '/customers/users-enum-values': '/customers/customers', + '/customers/addresses': '/customers/customers', + '/customers/statuses': '/customers/customers', + '/customers/statuses-enum-values': '/customers/customers', + '/customers/favorites': '/customers/customers', + '/customers/customers-info': '/customers/customers', + '/customers/bonus-operations': '/customers/customers', + '/customers/product-subscribes': '/customers/customers', + '/customers/preferences': '/customers/customers', + '/customers/orders': '/customers/customers', + '/marketing/promo-code-statuses': '/marketing/promo-codes', + '/marketing/discount*': '/marketing/discounts', + '/communication/types': '/communication/common', + '/communication/notification-setting*': '/communication/notification-settings', + '/units/sellers/seller-statuses': '/units/sellers', + '/units/seller-enum-values': '/units/sellers', + '/units/stores-workings': '/units/stores', + '/units/stores-pickup-times': '/units/stores', + '/units/stores-contacts': '/units/stores', + '/units/seller-users': '/units/sellers', + '/units/seller-user-roles': '/units/sellers', + '/units/admin-user-enum-values': '/units/admin-users', + '/units/admin-users/mass/change-active': '/units/admin-users', + '/units/admin-users/set-password': '/units/admin-users', + '/units/admin-users/right-access': '/units/admin-users', + '/units/admin-user-roles': '/units/admin-users', + }); return { document: this.schemaObject, operations, - derefedPathGroupedOps, - groups, }; } } diff --git a/src/common/helpers.ts b/src/common/helpers.ts index e3b1044..1e0d915 100644 --- a/src/common/helpers.ts +++ b/src/common/helpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/no-for-loop */ import { kebab } from 'case'; import { spawn } from 'node:child_process'; import { basename, join } from 'node:path'; @@ -76,36 +77,19 @@ const extractQueryKey = (original: OpenAPIV3.OperationObject) => { return kebab(original.operationId!); }; -function removeTrailingSlash(str: string): string { - return str.endsWith('/') ? str.slice(0, -1) : str; -} - -function extractInvalidatePrefix(oldPath: string): string { - const path = removeTrailingSlash(oldPath); - - const lastIndexOfColon = path.lastIndexOf(':'); - if (lastIndexOfColon !== -1) return path.slice(0, lastIndexOfColon); - - if (path.split('/').length <= 3) return path; - - const lastIndexOfSlash = path.lastIndexOf('/'); - - return path.slice(0, lastIndexOfSlash); -} - const generateInvalidationTargets = (op: AugmentedOperation, allOperations: AugmentedOperation[]) => { if (!op.isMutation) return []; - const prefix = extractInvalidatePrefix(op.path); + const file = op.storePath; const results = allOperations.filter(e => { - if (!e.path.startsWith(prefix)) return false; + if (e.storePath !== file) return false; if (!SEARCH_OPCODES.includes(parseOpcode(e))) return false; - if (e.path.endsWith('id}')) return true; - if (e.path.endsWith('Id}')) return true; - if (e.path.endsWith(':search')) return true; - if (e.path.endsWith(':search-one')) return true; + if (e.originalPath.endsWith('id}')) return true; + if (e.originalPath.endsWith('Id}')) return true; + if (e.originalPath.endsWith(':search')) return true; + if (e.originalPath.endsWith(':search-one')) return true; return false; }); @@ -143,32 +127,88 @@ export const removeTrailingLineBreak = (str: string) => { return str; }; -export const augmentPathsOperations = (paths: OpenAPIV3.PathsObject, config: ConfigSchema) => { - const pathNames = Object.keys(paths); +export const defaultGetDirectory = (routePath: string) => { + let directory = ''; + let lastSlashIndex = 0; + + // eslint-disable-next-line unicorn/no-for-loop + for (let i = 0; i < routePath.length; i++) { + const char = routePath[i]; + + if (char === '{') return directory.slice(0, lastSlashIndex); + if (char === ':') return directory; + + if (char === '/') lastSlashIndex = i; + + directory += char; + } + + return directory; +}; + +type DirectoryGetter = (routePath: string) => string; + +const isRewrite = (from: string, path: string) => { + const isWildcard = from.endsWith('*'); + + if (isWildcard) { + for (let i = 0; i < from.length; i++) { + const fromChar = from[i]; + const toChar = path[i]; + + if (fromChar === '*' && i <= path.length) return true; + + if (fromChar !== toChar) return false; + } + } + + return from === path; +}; + +const applyFirstRewrite = (rewrites: Record, path: string) => { + for (const from in rewrites) { + if (isRewrite(from, path)) return rewrites[from]; + } + + return path; +}; + +export const augmentPathsOperations = ( + paths: OpenAPIV3.PathsObject, + config: ConfigSchema, + directoryGetter: DirectoryGetter = defaultGetDirectory, + rewrites: Record = {} +) => { + const originalPaths = Object.keys(paths); - const allOperations = pathNames.flatMap(pathName => { - const groupName = extractSegment(pathName)!; - const operationInfo = paths[pathName]!; + const allOperations = originalPaths.flatMap(originalPath => { + const group = extractSegment(originalPath)!; + const operationInfo = paths[originalPath]!; const httpMethods = (Object.keys(operationInfo) as HttpMethod[]).filter(e => (e as any) !== '$reference'); - return httpMethods.map(httpMethod => { - const reqInfo = operationInfo[httpMethod] as OpenAPIV3.OperationObject; + return httpMethods.map(method => { + const original = operationInfo[method] as OpenAPIV3.OperationObject; + + const pathWithoutLeadingSlash = originalPath.startsWith('/') ? originalPath.slice(1) : originalPath; + const storePath = applyFirstRewrite(rewrites, directoryGetter(originalPath)); return { - original: reqInfo, - hookName: generateHookName(reqInfo), - queryKey: extractQueryKey(reqInfo), - queryParams: extractQueryParams(reqInfo), - group: groupName, - path: pathName, - method: httpMethod, - isMutation: isOperationMutation(httpMethod, reqInfo), - pathSubstituted: replacePathVariables(pathName), - pathVariables: extractPathVariables(pathName), + original, + storePath, + group, + originalPath, + method, invalidationTargets: [], - isFileUpload: hasFileUpload(reqInfo), - hasPathParams: hasPathParams(reqInfo), + parentDescription: operationInfo.summary || operationInfo.description || null, + hookName: generateHookName(original), + queryKey: extractQueryKey(original), + queryParams: extractQueryParams(original), + isMutation: isOperationMutation(method, original), + pathSubstituted: replacePathVariables(pathWithoutLeadingSlash), + pathVariables: extractPathVariables(originalPath), + isFileUpload: hasFileUpload(original), + hasPathParams: hasPathParams(original), }; }); }); @@ -193,20 +233,6 @@ export const extractSegment = (path: string) => { return segments[1]; }; -export const groupOperations = (flatOperation: AugmentedOperation[]) => - flatOperation.reduce((acc, cur) => { - const groupName = extractSegment(cur.path); - if (!groupName) return acc; - - if (!(groupName in acc)) { - acc[groupName] = []; - } - - acc[groupName].push(cur); - - return acc; - }, {} as Record); - export const renderImports = (sourceFile: SourceFile, imports: ImportData[]) => { const map = new Map(); diff --git a/src/common/types.ts b/src/common/types.ts index 45e9ef8..eb679a0 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,6 +1,8 @@ import { JSONSchema } from '@stoplight/json-schema-ref-parser'; import { OpenAPIV3 } from 'openapi-types'; +import { HttpMethod } from './helpers'; + export interface ImportData { from: string; name: string; @@ -8,12 +10,20 @@ export interface ImportData { } export type AugmentedOperation = { + parentDescription: string | null; + original: OpenAPIV3.OperationObject; - path: string; + originalPath: string; + + /** + * Папка где будет лежать метод, и типы + */ + storePath: string; + pathSubstituted: string; pathVariables: string[]; queryParams: OpenAPIV3.ParameterObject[]; - method: string; + method: HttpMethod; group: string; isMutation: boolean; @@ -37,3 +47,4 @@ export interface RefSchemaData { schema: JSONSchema; } +export type OperationsToPathsFn = (operations: AugmentedOperation[]) => Record; diff --git a/src/typegen/JsonSchemaRenderer.ts b/src/typegen/JsonSchemaRenderer.ts index 6ff03ef..3bd8029 100644 --- a/src/typegen/JsonSchemaRenderer.ts +++ b/src/typegen/JsonSchemaRenderer.ts @@ -23,6 +23,8 @@ export type RenderElement = { needsParenthesis: boolean; }; +const customMeta = false; + export default class JsonSchemaRenderer { private getInterfaceName: InterfaceNameFunction; private cache = new WeakMap(); @@ -85,19 +87,19 @@ export type RequireKeys = }; const dataType = processSchema(dataSchema, dataReference, demandedRefs); - const metaType = processSchema(metaSchema, metaReference, demandedRefs); - const deps: RenderElement[] = [dataType, metaType]; + const metaType = customMeta ? processSchema(metaSchema, metaReference, demandedRefs) : null; + const deps: RenderElement[] = metaType ? [dataType, metaType] : [dataType]; return { definition: { description, - code: `export type ${typeName} = CommonResponse<${dataType.name}, ${metaType.name}>;`, + code: metaType ? `export type ${typeName} = CommonResponse<${dataType.name}, ${metaType.name}>;` : `export type ${typeName} = CommonResponse<${dataType.name}>;`, }, deps, extraImports: [ { - fromOutput: 'helpers', + fromOutput: './helpers.ts', name: 'CommonResponse', }, ], @@ -215,7 +217,7 @@ export type RequireKeys = const keysToRequireArr = [...keysToRequire.values()]; const extraImports: ImportStatement[] = [ { - fromOutput: 'helpers', + fromOutput: './helpers.ts', name: 'Prettify', }, ]; @@ -228,7 +230,7 @@ export type RequireKeys = .join('|')}>>;`; extraImports.push({ - fromOutput: 'helpers', + fromOutput: './helpers.ts', name: 'RequireKeys', }); } else { @@ -511,6 +513,18 @@ export type RequireKeys = return result; } + if ((schema.type || '').startsWith('string')) { + return { + needsParenthesis: false, + type: 'literal', + definition: { code: '', description: '' }, + name: schema.type, + deps: [], + reference, + extraImports: [], + }; + } + switch (schema.type) { case undefined: case 'object': { diff --git a/src/typegen/TypeRenderer.ts b/src/typegen/TypeRenderer.ts index 96ceb98..4ba7923 100644 --- a/src/typegen/TypeRenderer.ts +++ b/src/typegen/TypeRenderer.ts @@ -1,4 +1,3 @@ -/* eslint-disable guard-for-in */ import { pascal } from 'case'; import type { JSONSchema4 } from 'json-schema'; import kleur from 'kleur'; @@ -9,18 +8,11 @@ import { OpenAPIV3 } from 'openapi-types'; import { Project } from 'ts-morph'; import { ParsedSchema } from '../common/SchemaParser'; -import { - HttpMethod, - extractRefAnchor, - extractSegment, - removeTrailingLineBreak, - renderImports, -} from '../common/helpers'; -import { ImportData, OverridePolicy } from '../common/types'; +import { HttpMethod, extractRefAnchor, removeTrailingLineBreak, renderImports } from '../common/helpers'; +import { AugmentedOperation, ImportData, OverridePolicy } from '../common/types'; import { ConfigSchema } from '../config/Config'; import { Reference } from '../deref'; import JsonSchemaRenderer, { InterfaceNameFunction, RenderElement } from './JsonSchemaRenderer'; -import refToTypesFile from './refToTypesFile'; export type TypeInfo = Omit & { importFrom: string; @@ -117,7 +109,8 @@ export class TypeRenderer { description: string, type: ContentType, reference: Reference, - schema: Record + schema: Record, + storePath: string ): TypeInfo { const typeName = typeNameFunction(reference); @@ -131,7 +124,7 @@ export class TypeRenderer { code: `export type ${typeName} = FormData;`, }, deps: [], - importFrom: refToTypesFile(reference), + importFrom: `${storePath}/types.ts`, name: typeName, reference, type: 'object', @@ -147,7 +140,7 @@ export class TypeRenderer { code: `export type ${typeName} = string;`, }, deps: [], - importFrom: refToTypesFile(reference), + importFrom: `${storePath}/types.ts`, name: typeName, reference, type: 'object', @@ -159,7 +152,9 @@ export class TypeRenderer { const traverse = (rendered: RenderElement): TypeInfo => { const ref = rendered.reference; - const importFrom = refToTypesFile(ref); + + const isEnum = rendered.type === 'enum'; + const importFrom = `${storePath}/${isEnum ? 'enums.ts' : 'types.ts'}`; const newTree: TypeInfo = { ...rendered, reference: ref, importFrom, deps: [] }; @@ -209,21 +204,16 @@ export class TypeRenderer { return { data, meta }; } - private renderRequest( - rootReference: Reference, - groupName: string, - operationInfo: OpenAPIV3.PathItemObject, - method: OpenAPIV3.OperationObject - ) { - if (!method.requestBody) return; + private tryRenderAsRequest(rootReference: Reference, operation: AugmentedOperation) { + if (!operation.original.requestBody) return; - const { content } = method.requestBody as OpenAPIV3.RequestBodyObject; + const { content } = operation.original.requestBody as OpenAPIV3.RequestBodyObject; const valid = ['application/json', 'multipart/form-data'] as const; for (const type of valid) { if (content[type]) { if (!content[type].schema) { - console.error(kleur.red('no schema for request body'), type, groupName, method.operationId); + console.error(kleur.red('no schema for request body'), type, operation.original.operationId); continue; } @@ -231,25 +221,21 @@ export class TypeRenderer { const reference = (value as any).$reference || rootReference; const description = - method.summary || method.description || operationInfo.summary || operationInfo.description || ''; + operation.original.summary || operation.original.description || operation.parentDescription || ''; - return this.renderJsonSchema(description, type, reference, content[type].schema!); + return this.renderJsonSchema(description, type, reference, content[type].schema!, operation.storePath); } } } - private renderResponse( - rootReference: Reference, - groupName: string, - operationInfo: OpenAPIV3.PathItemObject, - method: OpenAPIV3.OperationObject - ) { + private tryRenderAsResponse(rootReference: Reference, operation: AugmentedOperation) { + const method = operation.original; + if (!method.responses) return; const codes = Object.keys(method.responses); - const description = - method.summary || method.description || operationInfo.summary || operationInfo.description || ''; + const description = method.summary || method.description || operation.parentDescription || ''; for (const code of codes) { let reference = rootReference; @@ -284,7 +270,7 @@ export class TypeRenderer { reference = (schema as any).$reference; } - return this.renderJsonSchema(description, type, reference, schema); + return this.renderJsonSchema(description, type, reference, schema, operation.storePath); } } } @@ -323,17 +309,22 @@ export class TypeRenderer { file.imports.push(...this.typeInfoImportsResolver(typeInfo, typeInfo.importFrom)); } - // extraImports всегда относительны helpers if (typeInfo.extraImports) { + const currentFolder = join(this.config.output_path, dirname(typeInfo.importFrom)); + file.imports.push( ...typeInfo.extraImports.map(e => { - const relativeImportPath = relative( - dirname(typeInfo.importFrom), - dirname(e.fromOutput) - ).replaceAll('\\', '/'); + const importFile = join(this.config.output_path, e.fromOutput); + const relativeImportPath = relative(currentFolder, importFile).replaceAll('\\', '/'); + + let from = relativeImportPath.replaceAll('.ts', ''); + + if (!from.startsWith('.')) { + from = `./${from}`; + } return { - from: `${relativeImportPath}/${basename(e.fromOutput)}`, + from, name: e.name, }; }) @@ -364,10 +355,16 @@ export class TypeRenderer { const schema = this.parsedSchema.document; - for (const path in schema.paths) { - const groupName = extractSegment(path)!; + for (const operation of this.parsedSchema.operations) { + const endpointTypes = this.upsertPathToEndpointTypeCache(operation.originalPath); - const reference: Reference = (schema.paths[path] as any).$reference || { + const httpMethod = operation.method; + + if (!endpointTypes[httpMethod]) { + endpointTypes[httpMethod] = {}; + } + + const reference: Reference = (schema.paths[operation.originalPath] as any).$reference || { absolutePath: '', targetObject: null, path: [], @@ -375,45 +372,27 @@ export class TypeRenderer { target: '', }; - const operationInfo = schema.paths[path] as OpenAPIV3.PathItemObject; - const httpMethods = Object.keys(operationInfo) as (HttpMethod | '$reference')[]; - - for (const httpMethod of httpMethods) { - if (httpMethod === '$reference') continue; + const req = this.tryRenderAsRequest(reference, operation); + if (req) { + this.renderTypeToFile(req); - const method = operationInfo[httpMethod]; - if (!method) continue; - - // if (method.operationId !== 'createBaseSynonymsJob') continue; - - const endpointTypes = this.upsertPathToEndpointTypeCache(path); - - if (!endpointTypes[httpMethod]) { - endpointTypes[httpMethod] = {}; - } - - const req = this.renderRequest(reference, groupName, operationInfo, method); - if (req) { - this.renderTypeToFile(req); - - endpointTypes[httpMethod]!.request = { - ...req, - deps: [], - extraImports: [], - definition: { code: '', description: '' }, - }; - } + endpointTypes[httpMethod]!.request = { + ...req, + deps: [], + extraImports: [], + definition: { code: '', description: '' }, + }; + } - const res = this.renderResponse(reference, groupName, operationInfo, method); - if (res) { - this.renderTypeToFile(res); - endpointTypes[httpMethod]!.response = { - ...res, - deps: [], - extraImports: [], - definition: { code: '', description: '' }, - }; - } + const res = this.tryRenderAsResponse(reference, operation); + if (res) { + this.renderTypeToFile(res); + endpointTypes[httpMethod]!.response = { + ...res, + deps: [], + extraImports: [], + definition: { code: '', description: '' }, + }; } } diff --git a/src/typegen/refToTypesFile.ts b/src/typegen/refToTypesFile.ts deleted file mode 100644 index 71dd781..0000000 --- a/src/typegen/refToTypesFile.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { camel } from 'case'; -import { basename, dirname } from 'node:path'; - -import { Reference } from '../deref'; - -const cache = new Map(); - -const uncachedRefToTypesFile = (ref: Reference) => { - let dir = dirname(ref.absolutePath) - .replaceAll('/schemas/', '') - .replaceAll('/schemas', '') - .replaceAll('schemas/', ''); - - if (dir.endsWith('/')) dir = dir.slice(0, -1); - - const originalFile = camel(basename(ref.absolutePath).replace('.yaml', '')); - - - const file = originalFile === 'enums' ? 'enums/index' : originalFile; - - const isEnum = dir.endsWith('enum') || dir.endsWith('enums') || file.toLowerCase().includes('enum'); - const suffix = isEnum ? '' : 'types/'; - - const result = `${dir}/${suffix}${file}.ts`; - if (result.startsWith('/')) return result.slice(1); - - return result; -}; - -const refToTypesFile = (ref: Reference) => { - if (cache.get(ref.absolutePath)) return cache.get(ref.absolutePath)!; - - const result = uncachedRefToTypesFile(ref); - cache.set(ref.absolutePath, result); - - return result; -}; - -export default refToTypesFile; diff --git a/yarn.lock b/yarn.lock index a25f15b..07c61ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4636,6 +4636,11 @@ micromatch@^4.0.2, micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" +middleware-io@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/middleware-io/-/middleware-io-2.8.1.tgz#89b5cbe16ea985402891e2d29b967be2dd4f6cb0" + integrity sha512-H0XftkexHKxxQsoCsItMzM7WU3S/rIFzL3T4guU8tWLKr7e5cVkdaZ+JQeeL+TB3OaHpqFi/ozYqQl69z2X6bg== + mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"