UNPKG

react-query-swagger

Version:

Generates react-query hooks for your API based on Swagger/OpenAPI definition

605 lines (545 loc) 20.9 kB
#!/usr/bin/env node 'use strict'; const { execSync } = require('child_process'); const fs = require('fs'); const { dirname, join, parse } = require('path'); const { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync, readdirSync, } = require('fs'); let args = process.argv.splice(2).join(' '); if (args.includes('/nswag')) { runNswag(); return; } const isVue = args.includes('/vue'); const isAngular = args.includes('/angular'); const isSolid = args.includes('/solid'); const isSvelte = args.includes('/svelte'); const isMinimal = args.includes('/minimal') || isAngular; const isV4 = isVue || isSolid || isSvelte || isAngular || args.includes('/tanstack'); // this one might be useful if you only want to have // to initialize Axios and baseUrl from a single place const noHooks = args.includes('/no-hooks'); const useRecommendedConfiguration = args.includes('/use-recommended-configuration') || isMinimal; let pathToTemplates = process.mainModule.filename .replace('cli.js', 'templates') .replace('.bin/react-query-swagger', 'react-query-swagger/templates'); //if the user specified a template directory, use that instead const queryForNonGetOperationsConditionRegex = args.match( /\/non-get-query-condition:"(?<condition>.*?)"/, ); let queryForNonGetOperationsCondition = queryForNonGetOperationsConditionRegex?.groups?.['condition']; const addQueryForPostRequestsStartingWithGetKeyword = args.includes( '/post-queries-start-with-get', ); if (addQueryForPostRequestsStartingWithGetKeyword) { queryForNonGetOperationsCondition = "operation.ActualOperationName | downcase | slice: 0, 3 | replace: 'get', 'true'"; } if (!queryForNonGetOperationsCondition) queryForNonGetOperationsCondition = "'false'"; //if the user specified a template directory, use that instead const templateDirRegex = args.match(/\/templateDirectory:"(?<path>.*?)"/) || args.match(/\/templateDirectory:(?<path>\S*)/); const templateDir = templateDirRegex?.groups?.['path']; if (templateDir) { pathToTemplates = templateDir; } if (isVue) { pathToTemplates = pathToTemplates.replace(/templates$/, 'templates_vue'); } else if (isAngular) { pathToTemplates = pathToTemplates.replace( /templates$/, 'templates_minimal_angular', ); } if (noHooks) { if (isVue) { throw new Error('/no-hooks option is incompatible with /vue'); } else if (isAngular) { throw new Error('/no-hooks option is incompatible with /angular'); } else if (isSolid) { throw new Error('/no-hooks option is incompatible with /solid'); } else if (isSvelte) { throw new Error('/no-hooks option is incompatible with /svelte'); } else if (isV4) { throw new Error('/no-hooks option is incompatible with /tanstack'); } pathToTemplates = pathToTemplates.replace( /templates$/, isMinimal ? 'templates_minimal_no_hooks' : 'templates_no_hooks', ); } else if (isMinimal) { pathToTemplates = pathToTemplates.replace(/templates$/, 'templates_minimal'); pathToTemplates = pathToTemplates.replace( /templates_vue$/, 'templates_minimal_vue', ); } else if (!isV4) { pathToTemplates = pathToTemplates.replace(/templates$/, 'templates_v3'); } if (useRecommendedConfiguration) { // otherwise optional parameters are generated as mandatory // E.g.: // -true: deletePet(petId: number, api_key?: string | null | undefined) // -false: deletePet(petId: number, api_key: string | null | undefined) args += ' /generateOptionalParameters:true'; // otherwise, if `typeStyle` is `Inteface`, there's no code to convert `Date` objects args += ' /typeStyle:Class'; /* otherwise PATCH dtos have all their properties defined as mandatory: * export interface PatchUserDto { * userName!: string | null; // should be: userName?: string | null; * } */ args += ' /markOptionalProperties:true'; /* * If we use `null` as null value, unnecessary code gets added to `.toJSON()` and `.init()` functions: * - toJSON(data?: any) { // nullValue:undefined * data = typeof data === 'object' ? data : {}; * data["enabled"] = this.enabled; * ... * * - toJSON(data?: any) { // nullValue:null * data = typeof data === 'object' ? data : {}; * data["enabled"] = this.enabled !== undefined ? this.enabled : <any>null; * * * - init(_data?: any) { // nullValue:undefined * if (_data) { * this.enabled = _data["enabled"]; * ... * * - init(_data?: any) { // nullValue:null * if (_data) { * this.enabled = _data["enabled"] !== undefined ? _data["enabled"] : <any>null; * */ args += ' /nullValue:undefined'; /* * This gives a typed-possibility to create classes from interfaces (otherwise you have to use `init(_data?: any)` method) */ args += ' /generateConstructorInterface:true'; /* * We need this to be able to use both `undefined` and `null` as values in PATCH requests */ args += ' /fix-null-undefined-serialization'; /* * This flag places each Client class into separate file and converts it to a module (removing the class declaration) * This makes clients tree-shakable, since the bundler could remove functions that are not actually used in code. * This flag is incompatible with /clientBaseClass and /generateClientInterfaces */ args += ' /modules'; } const isClientsAsModules = isAngular || args.includes('/modules') || args.includes('/clients-as-modules'); const extractTypes = args.includes('/extract-types') || isMinimal; if (isClientsAsModules) { if ( args.includes('/clientBaseClass') || args.includes('/generateClientInterfaces') ) { console.error( '/clients-as-modules flag is incompatible with /clientBaseClass and /generateClientInterfaces', ); throw new Error( '/clients-as-modules flag is incompatible with /clientBaseClass and /generateClientInterfaces', ); } } const sourceFolder = isClientsAsModules ? 'modules' : 'original'; copyFileSync( join(pathToTemplates, sourceFolder, 'AxiosClient.liquid'), join(pathToTemplates, '_AxiosClient.liquid'), ); copyFileSync( join(pathToTemplates, sourceFolder, 'FetchClient.liquid'), join(pathToTemplates, '_FetchClient.liquid'), ); readdirSync(join(pathToTemplates, sourceFolder)) .filter( (fileName) => fileName !== 'AxiosClient.liquid' && fileName !== 'FetchClient.liquid', ) .forEach((fileName) => { copyFileSync( join(pathToTemplates, sourceFolder, fileName), join(pathToTemplates, fileName), ); }); if (isMinimal) { // replace // const content_ = JSON.stringify({{ operation.ContentParameter.VariableName }}); // with // const content_ = Types.serialize{{ operation.ContentParameter.ClassName }}({{ operation.ContentParameter.VariableName }}); patchTemplateFile(pathToTemplates, 'Client.RequestBody.liquid', (content) => content.replace( 'const content_ = JSON.stringify({{ operation.ContentParameter.Type }});', 'const content_ = Types.serialize{{ operation.ContentParameter.Type }}({{ operation.ContentParameter.VariableName }});', ), ); } if (queryForNonGetOperationsCondition) { patchTemplateFile(pathToTemplates, 'ReactQuery.liquid', (content) => content.replaceAll( /\{%- assign AddQueryForNonGetOperationCondition.*?%\}/gims, `{%- assign AddQueryForNonGetOperationCondition = ${queryForNonGetOperationsCondition} -%}`, ), ); } const toExecute = `npx nswag-portable openapi2tsclient /templateDirectory:"${pathToTemplates}" /typeScriptVersion:"4" ${args}`; try { execSync(toExecute, function (e, stdout, stderr) { console.log(stdout); }); } catch (e) { throw new Error(e?.output?.toString()); } const outputRegex = args.match(/\/output:"(?<path>.*?)"/) || args.match(/\/output:(?<path>\S*)/); const outputPath = outputRegex?.groups?.['path']; if (!outputPath) { throw new Error('Unable to parse "/output" option from command line args'); } const outputDir = dirname(outputPath); const outputFileWithoutExtension = parse(outputPath).name; if (args.includes('/fix-null-undefined-serialization')) { let apiClient = readFileSync(outputPath, 'utf-8'); // Replace DTO type definitions: // export interface IUser { // id?: number | undefined; -> id?: number | null; // } // Replaced because this is what server (at least .NET :)) actually returns apiClient = apiClient.replaceAll('| undefined;', '| null;'); /* this changes `init()` function to be like: * this.lastChangeDateTime = _data["lastChangeDateTime"] ? new Date(_data["lastChangeDateTime"].toString()) : <any>undefined; * to * this.lastChangeDateTime = _data["lastChangeDateTime"] ? new Date(_data["lastChangeDateTime"].toString()) : <any>null; * * Again, server actually returns `null`, we don't want to change that. */ apiClient = apiClient.replaceAll(': <any>undefined', ': <any>null'); /* We adjust `toJSON` methods, so that if some fields have `undefined` values they are NOT converted to null * toJSON(data?: any) { * data = typeof data === 'object' ? data : {}; * // convert this * data["privacySettings"] = this.privacySettings ? this.privacySettings.toJSON() : <any>null; * // to this * data["privacySettings"] = this.privacySettings? this.privacySettings.toJSON() : this.privacySettings; * return data; * } */ apiClient = apiClient.replaceAll( /\? this\.(.*?)\.toJSON\(\) : <any>null/gim, '? this.$1.toJSON() : this.$1', ); /* * Perform the following change (in `toJSON()` method): * data["shipDate"] = this.shipDate ? this.shipDate.toISOString() : <any>null; * to * data["shipDate"] = this.shipDate && this.shipDate.toISOString(); * * This is to be able to send both `undefined` and `null` to the server (important for PATCH requests) */ apiClient = apiClient.replaceAll( /\? this\.(.*?)\.toISOString\(\) : <any>null/gim, '&& this.$1.toISOString()', ); /* * Perform the following change (in `toJSON()` method): * data["shipDate"] = this.shipDate ? formatDate(this.shipDate) : <any>null; * to * data["shipDate"] = this.shipDate && formatDate(this.shipDate); * * This is to be able to send both `undefined` and `null` to the server (important for PATCH requests) */ apiClient = apiClient.replaceAll( /\? formatDate\((.*?)\) : <any>null/gim, '&& formatDate($1)', ); writeFileSync(outputPath, apiClient); } if (isVue) { let apiClient = readFileSync(outputPath, 'utf-8'); apiClient = apiClient.replaceAll( /@tanstack\/react-query/gim, '@tanstack/vue-query', ); apiClient = apiClient.replaceAll( /\s+const metaContext = useContext\(QueryMetaContext\);/gim, '', ); apiClient = apiClient.replaceAll( /\s+options = addMetaToOptions\(options, metaContext\);/gim, '', ); apiClient = apiClient .replaceAll(/UseQueryResult/gim, 'UseQueryReturnType') .replaceAll(/UseMutationResult/gim, 'UseMutationReturnType'); writeFileSync(outputPath, apiClient); } else if (isAngular) { let apiClient = readFileSync(outputPath, 'utf-8'); apiClient = apiClient.replaceAll( /\s+const metaContext = useContext\(QueryMetaContext\);/gim, '', ); apiClient = apiClient.replaceAll( /\s+options = addMetaToOptions\(options, metaContext\);/gim, '', ); apiClient = apiClient.replaceAll( /addMetaToOptions \} from '.\/helpers'/gim, "} from './helpers'", ); apiClient = apiClient.replaceAll( /UseQueryOptions<(.*?)>/gim, "Optional<UndefinedInitialDataOptions<$1>, 'queryFn'>", ); apiClient = apiClient.replaceAll( /UseMutationOptions<(.*?)>/gim, "Optional<CreateMutationOptions<$1>, 'mutationFn'>", ); apiClient = apiClient.replaceAll(/: UseMutationResult<.*?>/gim, ''); apiClient = apiClient.replaceAll( /export class ApiException extends Error \{\s+message: string;/gm, 'export class ApiException extends Error {\n', ); apiClient = apiClient.replaceAll(/useQuery</gm, 'injectQuery()<'); apiClient = apiClient.replaceAll(/useMutation\(/gm, 'injectMutation()('); writeFileSync(outputPath, apiClient); } // we will extract every Controller into separate file. // these files will be put in `queryFolderName` directory (with the same name as the output file) const queryFolderName = outputFileWithoutExtension; const queryDir = join(outputDir, queryFolderName); if (existsSync(queryDir)) { rmSync(queryDir, { recursive: true, force: true }); } mkdirSync(queryDir); if (isClientsAsModules) { let apiClient = readFileSync(outputPath, 'utf-8'); const clientClasses = apiClient.matchAll( /\/\/-----ClientClass--(?<name>[^-]*)---(?<content>[\s\S]*?)\/\/-----\/ClientClass----/gims, ); for (let clientClass of clientClasses) { let { name, content } = clientClass.groups; const foundText = clientClass[0]; const fileName = join(queryDir, `${name}.ts`); apiClient = apiClient.replace( foundText, `export * as ${name} from './${queryFolderName}/${name}';`, ); content = postProcessClientContent( content, outputFileWithoutExtension, false, ); writeFileSync(fileName, content); } apiClient = apiClient .replace('function throwException', 'export function throwException') .replace('function isAxiosError', 'export function isAxiosError') .replace('function formatDate', 'export function formatDate') .replace( "import axios, { AxiosError } from 'axios';", "import type { AxiosError } from 'axios';", ); writeFileSync(outputPath, apiClient); } else { let apiClient = readFileSync(outputPath, 'utf-8'); // remove duplicated imports from every Client file apiClient = apiClient .replaceAll( "import type { AxiosRequestConfig, AxiosResponse, CancelToken } from 'axios';", '', ) .replace('function formatDate', 'export function formatDate') // .replaceAll( // 'cancelToken?: CancelToken | undefined{% endif %})', // 'cancelToken?: CancelToken | undefined{% endif %}, axiosConfig?: AxiosRequestConfig | undefined)', // ) .replaceAll(',axiosConfig )', ')') .replaceAll('axiosConfig )', ')'); writeFileSync(outputPath, apiClient); } if (true) { // split react-query Controller per file let apiClient = readFileSync(outputPath, 'utf-8'); if (isClientsAsModules && extractTypes) { const types = apiClient.match( /\/\/-----Types\.File-----(?<content>.*?)\/\/-----\/Types.File-----/gims, ); apiClient = apiClient.replace(types[0], ''); let clientContent = types[0]; clientContent = clientContent .replaceAll(/Types\.init/g, 'init') .replaceAll(/Types\.deserialize/g, 'deserialize'); if (isMinimal) clientContent = processForMinimalApi(clientContent); writeFileSync( join(outputDir, `${outputFileWithoutExtension}.types.ts`), clientContent, ); } apiClient = extractQueryHelperFunctions(apiClient, queryDir); const queryClasses = apiClient.matchAll( /\/\/-----ReactQueryClass--(?<name>[^-]*)---(?<content>[\s\S]*?)\/\/-----\/ReactQueryClass----/gims, ); for (let queryClass of queryClasses) { let { name, content } = queryClass.groups; const foundText = queryClass[0]; const fileName = join(queryDir, `${name}.ts`); content = postProcessClientContent( content, outputFileWithoutExtension, true, ); if (content) { writeFileSync(fileName, content); apiClient = apiClient.replace( foundText, `export * as ${name} from './${queryFolderName}/${name}';`, ); } else { apiClient = apiClient.replace(foundText, ``); } } apiClient = apiClient .replaceAll(`from './helpers';`, `from './${queryFolderName}/helpers';`) .replaceAll(/from '.\/client/g, `from '.\/${outputFileWithoutExtension}`); writeFileSync(outputPath, apiClient); } function extractQueryHelperFunctions(apiClient, queryDir) { const helperFunctionsMatch = apiClient.match( /\/\/-----ReactQueryFile-----(?<content>[\s\S]*?)\/\/-----\/ReactQueryFile----/gims, ); const foundText = helperFunctionsMatch[0]; const addResultTypeFactories = apiClient.matchAll( /\/\/-----PersistorHydrator-----(?<content>[\s\S]*?)\/\/-----\/PersistorHydrator-----/gims, ); let addResultTypeFactoryText = ''; for (let addResultTypeFactory of addResultTypeFactories) { let { content } = addResultTypeFactory.groups; addResultTypeFactoryText = addResultTypeFactoryText + content + '\n'; apiClient = apiClient.replace(addResultTypeFactory[0], ``); } apiClient = apiClient.replace( '/*--addResultTypeFactory-placeholder--*/', addResultTypeFactoryText, ); const fileName = join(queryDir, `helpers.ts`); writeFileSync(fileName, foundText); apiClient = apiClient.replace(foundText, ''); return apiClient; } function postProcessClientContent( content, outputFileWithoutExtension, isQuery, ) { if (!content.trim()) return ''; content = content .replaceAll( ` from '../client';`, isMinimal ? ` from '../${outputFileWithoutExtension}.types';` : ` from '../${outputFileWithoutExtension}';`, ) .replaceAll('this.baseUrl +', 'getBaseUrl() +') .replaceAll('this.jsonParseReviver', 'getJsonParseReviver()') .replaceAll( /Types\.\{ \[key: ((string)|(number))]: /g, '{ [key: $1]: Types.', ) // For the case when an enum is used as keys of a dictionary .replaceAll( /Types\.\{ \[key in keyof typeof ([a-zA-Z0-9_]*?)]\?: /g, '{ [key in keyof typeof Types.$1]: Types.', ) .replaceAll('parseDateOnly(', 'Types.parseDateOnly(') .replaceAll('Types.{', '{') .replaceAll(/Types.string(?![a-zA-Z0-9_])/g, 'string') .replaceAll(/Types.number(?![a-zA-Z0-9_])/g, 'number') .replaceAll(/Types.boolean(?![a-zA-Z0-9_])/g, 'boolean') .replaceAll(/Types\.Date(?![a-zA-Z0-9_])/g, 'Date') .replaceAll(/Types.void(?![a-zA-Z0-9_])/g, 'void') .replaceAll(/Types.unknown(?![a-zA-Z0-9_])/g, 'unknown') .replaceAll(/Types.any(?![a-zA-Z0-9_])/g, 'any') .replaceAll(/Types.Record(?![a-zA-Z0-9_])/g, 'Record') .replaceAll('formatDate(', 'Types.formatDate(') .replaceAll(/([a-zA-Z0-9_]*?)\.fromJS\(/g, 'Types.$1.fromJS(') // this is for correctly handling HTTP actions returning Dictionary<string, MyClass>. // it should correctly include the `Types.` prefix. // E.g.: (<any>result200)![key] = resultData200[key] ? Types.MyClass.fromJS(resultData200[key]) : new Types.MyClass(); .replaceAll( /Types\.([a-zA-Z0-9_]*?)\.fromJS\(resultData200\[key\]\) : new /g, 'Types.$1.fromJS(resultData200[key]) : new Types.', ); // we can't `import type * as Types`, because even in *Query files we use e.g. `Types.formatDate` const additionalImport = extractTypes ? `import * as Types from '../${outputFileWithoutExtension}.types';\n` : `import * as Types from '../${outputFileWithoutExtension}';\n`; content = content.replace('import', additionalImport + 'import').trim(); return content; } function processForMinimalApi(apiClient) { apiClient = apiClient.replaceAll( /function parseDateOnly/gim, 'export function parseDateOnly', ); const split = apiClient.split('//-----/CustomTypes.File-----'); if (split[1].includes('AxiosError')) { split[1] = "import type { AxiosError } from 'axios'" + split[1]; } apiClient = split[0] .replaceAll(/this\./gim, '_data.') .replaceAll(/implements \S+/gim, '') + split[1]; // remove empty clauses like: // if (_data["errors"]) { // for (let key in _data["errors"]) { // } // } apiClient = apiClient.replaceAll( /if \(_data\["\S+"\]\) \{\s+for \(let key in _data\["\S+"\]\) \{\s+\}\s+\}/gim, '', ); // remove empty lines if line only contain spaces apiClient = apiClient.replaceAll(/\r\n\s+\r\n/gim, '\r\n'); apiClient = apiClient.replaceAll(/\n\s+\n/gim, '\n'); // remove empty blocks like that: // if (_data) { // } apiClient = apiClient.replaceAll(/if \(_data\) \{\s+\}\r?\n?/gim, ''); return apiClient; } function patchTemplateFile(pathToTemplates, file, postProcess) { const fullFilePath = join(pathToTemplates, file); let content = fs.readFileSync(fullFilePath).toString(); content = postProcess(content); fs.writeFileSync(fullFilePath, content); } /* * Runs NSwag with all passed arguments. * Useful to be able to generate C# clients without installing NSwag separately */ function runNswag() { const toExecute = `npx nswag-portable ${args}`; try { execSync(toExecute, function (e, stdout, stderr) { console.log(stdout); }); } catch (e) { throw new Error(e?.output?.toString()); } }