diff --git a/.eslintrc.js b/.eslintrc.js index c969b0179..67bd8ca82 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { - extends: ['prettier'], + root: true, + extends: ['prettier', 'turbo'], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 6, diff --git a/.gitignore b/.gitignore index 1debcb4b6..1c0510ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,5 @@ typings/ /.idea package-lock.json -tests/generated/** \ No newline at end of file +tests/generated/** +.turbo \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index c22f9d878..44c0219c0 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -2,5 +2,5 @@ . "$(dirname "$0")/_/husky.sh" yarn lint -yarn test --run +yarn test:ci yarn format:staged diff --git a/package.json b/package.json index b75a66704..bca6d7f40 100644 --- a/package.json +++ b/package.json @@ -1,85 +1,45 @@ { - "name": "orval", - "description": "A swagger client generator for typescript", - "version": "6.10.3", + "name": "orval-workspaces", + "namespace": "@orval", + "version": "6.11.0-alpha.1", "license": "MIT", - "files": [ - "dist" + "workspaces": [ + "packages/*" ], - "bin": { - "orval": "dist/bin/orval.js" - }, - "main": "dist/index.js", - "keywords": [ - "rest", - "client", - "swagger", - "open-api", - "fetch", - "data fetching", - "code-generation", - "angular", - "react", - "react-query", - "svelte", - "svelte-query", - "vue", - "vue-query", - "msw", - "mock", - "axios", - "vue-query", - "vue", - "swr" - ], - "author": { - "name": "Victor Bury", - "email": "bury.victor@gmail.com" - }, - "repository": { - "type": "git", - "url": "https://github.com/anymaniax/orval" - }, + "private": true, "scripts": { - "build": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --minify --clean --dts --splitting", - "dev": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --clean --watch src --onSuccess 'yarn generate-api'", - "lint": "eslint src/**/*.ts", - "test": "vitest --global test.ts", + "old-build": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --minify --clean --dts --splitting", + "old-dev": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --clean --watch src --onSuccess 'yarn generate-api'", + "old-lint": "eslint src/**/*.ts", + "old-test": "vitest --global test.ts", "format": "prettier --write .", "format:staged": "pretty-quick --staged", "prerelease": "yarn build && cd ./tests && yarn generate && yarn build", "release": "dotenv release-it", "postrelease": "yarn build && yarn update-samples", - "generate-api": "node ./dist/bin/orval.js --config ./samples/react-query/basic/orval.config.ts --watch", "prepare": "husky install && cd ./samples/react-query/basic && yarn", "commitlint": "commitlint", - "update-samples": "zx ./scripts/update-samples.mjs" + "update-samples": "zx ./scripts/update-samples.mjs", + "build": "turbo run build", + "test": "turbo run test", + "test:ci": "turbo run test -- --run", + "lint": "turbo run lint", + "dev": "turbo run dev" }, "devDependencies": { "@commitlint/cli": "^17.0.3", "@commitlint/config-conventional": "^17.0.3", "@faker-js/faker": "^7.4.0", - "@release-it/conventional-changelog": "^5.0.0", - "@types/chalk": "^2.2.0", - "@types/commander": "^2.12.2", - "@types/fs-extra": "^9.0.13", - "@types/inquirer": "^8.2.2", - "@types/lodash.get": "^4.4.7", - "@types/lodash.omit": "^4.5.7", - "@types/lodash.omitby": "^4.6.7", - "@types/lodash.uniq": "^4.5.7", - "@types/lodash.uniqby": "^4.7.7", - "@types/lodash.uniqwith": "^4.5.7", - "@types/micromatch": "^4.0.2", "@types/node": "^18.7.3", "@types/prettier": "^2.7.0", - "@types/request": "^2.48.8", - "@types/validator": "^13.7.5", + "@release-it/conventional-changelog": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.33.0", "@typescript-eslint/parser": "^5.33.0", "dotenv-cli": "^6.0.0", + "esbuild-plugin-alias": "^0.2.1", "eslint": "^8.22.0", "eslint-config-prettier": "^8.5.0", + "eslint-config-turbo": "^0.0.4", "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.1", "lint-staged": "^13.0.3", @@ -89,40 +49,9 @@ "release-it": "^15.3.0", "rimraf": "^3.0.2", "tsup": "^6.2.2", + "turbo": "^1.6.3", "typescript": "^4.7.4", "vitest": "^0.6.3", "zx": "^7.0.8" - }, - "dependencies": { - "@apidevtools/swagger-parser": "^10.1.0", - "acorn": "^8.8.0", - "cac": "^6.7.12", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "compare-versions": "^4.1.3", - "cuid": "^2.1.8", - "debug": "^4.3.4", - "esbuild": "^0.15.3", - "esutils": "2.0.3", - "execa": "^5.1.1", - "find-up": "5.0.0", - "fs-extra": "^10.1.0", - "globby": "11.1.0", - "ibm-openapi-validator": "^0.88.0", - "inquirer": "^8.2.4", - "lodash.get": "^4.4.2", - "lodash.omit": "^4.5.0", - "lodash.omitby": "^4.6.0", - "lodash.uniq": "^4.5.0", - "lodash.uniqby": "^4.7.0", - "lodash.uniqwith": "^4.5.0", - "micromatch": "^4.0.5", - "openapi3-ts": "^3.0.0", - "string-argv": "^0.3.1", - "swagger2openapi": "^7.0.8", - "tsconfck": "^2.0.1", - "upath": "^2.0.1", - "url": "^0.11.0", - "validator": "^13.7.0" } } diff --git a/packages/angular/README.md b/packages/angular/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/angular/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/angular/package.json b/packages/angular/package.json new file mode 100644 index 000000000..5ac74c402 --- /dev/null +++ b/packages/angular/package.json @@ -0,0 +1,18 @@ +{ + "name": "@orval/angular", + "version": "6.11.0-alpha.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts" + }, + "dependencies": { + "@orval/core": "6.11.0-alpha.1" + } +} diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts new file mode 100644 index 000000000..5d9f5e1f8 --- /dev/null +++ b/packages/angular/src/index.ts @@ -0,0 +1,240 @@ +import { + ClientBuilder, + ClientDependenciesBuilder, + ClientFooterBuilder, + ClientGeneratorsBuilder, + ClientHeaderBuilder, + ClientTitleBuilder, + generateFormDataAndUrlEncodedFunction, + generateMutatorConfig, + generateMutatorRequestOptions, + generateOptions, + generateVerbImports, + GeneratorDependency, + GeneratorOptions, + GeneratorVerbOptions, + isBoolean, + pascal, + sanitize, + toObjectString, + VERBS_WITH_BODY, +} from '@orval/core'; + +const ANGULAR_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { name: 'HttpClient', values: true }, + { name: 'HttpHeaders' }, + { name: 'HttpParams' }, + { name: 'HttpContext' }, + ], + dependency: '@angular/common/http', + }, + { + exports: [{ name: 'Injectable', values: true }], + dependency: '@angular/core', + }, + { + exports: [{ name: 'Observable', values: true }], + dependency: 'rxjs', + }, +]; + +const returnTypesToWrite: Map = new Map(); + +export const getAngularDependencies: ClientDependenciesBuilder = () => + ANGULAR_DEPENDENCIES; + +export const generateAngularTitle: ClientTitleBuilder = (title) => { + const sanTitle = sanitize(title); + return `${pascal(sanTitle)}Service`; +}; + +export const generateAngularHeader: ClientHeaderBuilder = ({ + title, + isRequestOptions, + isMutator, + isGlobalMutator, + provideIn, +}) => ` +${ + isRequestOptions && !isGlobalMutator + ? `type HttpClientOptions = { + headers?: HttpHeaders | { + [header: string]: string | string[]; + }; + context?: HttpContext; + observe?: any; + params?: HttpParams | { + [param: string]: string | number | boolean | ReadonlyArray; + }; + reportProgress?: boolean; + responseType?: any; + withCredentials?: boolean; +};` + : '' +} + +${ + isRequestOptions && isMutator + ? `// eslint-disable-next-line + type ThirdParameter any> = T extends ( + config: any, + httpClient: any, + args: infer P, +) => any + ? P + : never;` + : '' +} + +@Injectable(${ + provideIn + ? `{ providedIn: '${isBoolean(provideIn) ? 'root' : provideIn}' }` + : '' +}) +export class ${title} { + constructor( + private http: HttpClient, + ) {}`; + +export const generateAngularFooter: ClientFooterBuilder = ({ + operationNames, +}) => { + let footer = '};\n\n'; + + operationNames.forEach((operationName) => { + if (returnTypesToWrite.has(operationName)) { + footer += returnTypesToWrite.get(operationName) + '\n'; + } + }); + + return footer; +}; + +const generateImplementation = ( + { + headers, + queryParams, + operationName, + response, + mutator, + body, + props, + verb, + override, + formData, + formUrlEncoded, + }: GeneratorVerbOptions, + { route, context }: GeneratorOptions, +) => { + const isRequestOptions = override?.requestOptions !== false; + const isFormData = override?.formData !== false; + const isFormUrlEncoded = override?.formUrlEncoded !== false; + const isExactOptionalPropertyTypes = + !!context.tsconfig?.compilerOptions?.exactOptionalPropertyTypes; + const isBodyVerb = VERBS_WITH_BODY.includes(verb); + const bodyForm = generateFormDataAndUrlEncodedFunction({ + formData, + formUrlEncoded, + body, + isFormData, + isFormUrlEncoded, + }); + + const dataType = response.definition.success || 'unknown'; + + returnTypesToWrite.set( + operationName, + `export type ${pascal( + operationName, + )}ClientResult = NonNullable<${dataType}>`, + ); + + if (mutator) { + const mutatorConfig = generateMutatorConfig({ + route, + body, + headers, + queryParams, + response, + verb, + isFormData, + isFormUrlEncoded, + hasSignal: false, + isBodyVerb, + isExactOptionalPropertyTypes, + }); + + const requestOptions = isRequestOptions + ? generateMutatorRequestOptions( + override?.requestOptions, + mutator.hasThirdArg, + ) + : ''; + + const propsImplementation = + mutator.bodyTypeName && body.definition + ? toObjectString(props, 'implementation').replace( + new RegExp(`(\\w*):\\s?${body.definition}`), + `$1: ${mutator.bodyTypeName}<${body.definition}>`, + ) + : toObjectString(props, 'implementation'); + + return ` ${operationName}(\n ${propsImplementation}\n ${ + isRequestOptions && mutator.hasThirdArg + ? `options?: ThirdParameter` + : '' + }) {${bodyForm} + return ${mutator.name}( + ${mutatorConfig}, + this.http, + ${requestOptions}); + } + `; + } + + const options = generateOptions({ + route, + body, + headers, + queryParams, + response, + verb, + requestOptions: override?.requestOptions, + isFormData, + isFormUrlEncoded, + isAngular: true, + isExactOptionalPropertyTypes, + hasSignal: false, + }); + + return ` ${operationName}(\n ${toObjectString( + props, + 'implementation', + )} ${ + isRequestOptions ? `options?: HttpClientOptions\n` : '' + } ): Observable {${bodyForm} + return this.http.${verb}(${options}); + } +`; +}; + +export const generateAngular: ClientBuilder = (verbOptions, options) => { + const imports = generateVerbImports(verbOptions); + const implementation = generateImplementation(verbOptions, options); + + return { implementation, imports }; +}; + +const angularClientBuilder: ClientGeneratorsBuilder = { + client: generateAngular, + header: generateAngularHeader, + dependencies: getAngularDependencies, + footer: generateAngularFooter, + title: generateAngularTitle, +}; + +export const builder = () => angularClientBuilder; + +export default builder; diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/angular/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/axios/README.md b/packages/axios/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/axios/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/axios/package.json b/packages/axios/package.json new file mode 100644 index 000000000..c8a9cd5ae --- /dev/null +++ b/packages/axios/package.json @@ -0,0 +1,18 @@ +{ + "name": "@orval/axios", + "version": "6.11.0-alpha.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts" + }, + "dependencies": { + "@orval/core": "6.11.0-alpha.1" + } +} diff --git a/packages/axios/src/index.ts b/packages/axios/src/index.ts new file mode 100644 index 000000000..6e32d1c19 --- /dev/null +++ b/packages/axios/src/index.ts @@ -0,0 +1,273 @@ +import { + ClientBuilder, + ClientDependenciesBuilder, + ClientFooterBuilder, + ClientGeneratorsBuilder, + ClientHeaderBuilder, + ClientTitleBuilder, + generateFormDataAndUrlEncodedFunction, + generateMutatorConfig, + generateMutatorRequestOptions, + generateOptions, + generateVerbImports, + GeneratorDependency, + GeneratorOptions, + GeneratorVerbOptions, + isSyntheticDefaultImportsAllow, + pascal, + sanitize, + toObjectString, + VERBS_WITH_BODY, +} from '@orval/core'; + +const AXIOS_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { + name: 'axios', + default: true, + values: true, + syntheticDefaultImport: true, + }, + { name: 'AxiosRequestConfig' }, + { name: 'AxiosResponse' }, + ], + dependency: 'axios', + }, +]; + +const returnTypesToWrite: Map string> = new Map(); + +export const getAxiosDependencies: ClientDependenciesBuilder = ( + hasGlobalMutator, +) => [...(!hasGlobalMutator ? AXIOS_DEPENDENCIES : [])]; + +const generateAxiosImplementation = ( + { + headers, + queryParams, + operationName, + response, + mutator, + body, + props, + verb, + override, + formData, + formUrlEncoded, + }: GeneratorVerbOptions, + { route, context }: GeneratorOptions, +) => { + const isRequestOptions = override?.requestOptions !== false; + const isFormData = override?.formData !== false; + const isFormUrlEncoded = override?.formUrlEncoded !== false; + const isExactOptionalPropertyTypes = + !!context.tsconfig?.compilerOptions?.exactOptionalPropertyTypes; + + const isSyntheticDefaultImportsAllowed = isSyntheticDefaultImportsAllow( + context.tsconfig, + ); + + const bodyForm = generateFormDataAndUrlEncodedFunction({ + formData, + formUrlEncoded, + body, + isFormData, + isFormUrlEncoded, + }); + const isBodyVerb = VERBS_WITH_BODY.includes(verb); + + if (mutator) { + const mutatorConfig = generateMutatorConfig({ + route, + body, + headers, + queryParams, + response, + verb, + isFormData, + isFormUrlEncoded, + isBodyVerb, + hasSignal: false, + isExactOptionalPropertyTypes, + }); + + const requestOptions = isRequestOptions + ? generateMutatorRequestOptions( + override?.requestOptions, + mutator.hasSecondArg, + ) + : ''; + + returnTypesToWrite.set( + operationName, + (title?: string) => + `export type ${pascal( + operationName, + )}Result = NonNullable['${operationName}']` + : `typeof ${operationName}` + }>>>`, + ); + + const propsImplementation = + mutator.bodyTypeName && body.definition + ? toObjectString(props, 'implementation').replace( + new RegExp(`(\\w*):\\s?${body.definition}`), + `$1: ${mutator.bodyTypeName}<${body.definition}>`, + ) + : toObjectString(props, 'implementation'); + + return `const ${operationName} = (\n ${propsImplementation}\n ${ + isRequestOptions && mutator.hasSecondArg + ? `options?: SecondParameter,` + : '' + }) => {${bodyForm} + return ${mutator.name}<${response.definition.success || 'unknown'}>( + ${mutatorConfig}, + ${requestOptions}); + } + `; + } + + const options = generateOptions({ + route, + body, + headers, + queryParams, + response, + verb, + requestOptions: override?.requestOptions, + isFormData, + isFormUrlEncoded, + isExactOptionalPropertyTypes, + hasSignal: false, + }); + + returnTypesToWrite.set( + operationName, + () => + `export type ${pascal(operationName)}Result = AxiosResponse<${ + response.definition.success || 'unknown' + }>`, + ); + + return `const ${operationName} = >(\n ${toObjectString(props, 'implementation')} ${ + isRequestOptions ? `options?: AxiosRequestConfig\n` : '' + } ): Promise => {${bodyForm} + return axios${ + !isSyntheticDefaultImportsAllowed ? '.default' : '' + }.${verb}(${options}); + } +`; +}; + +export const generateAxiosTitle: ClientTitleBuilder = (title) => { + const sanTitle = sanitize(title); + return `get${pascal(sanTitle)}`; +}; + +export const generateAxiosHeader: ClientHeaderBuilder = ({ + title, + isRequestOptions, + isMutator, + noFunction, +}) => ` +${ + isRequestOptions && isMutator + ? `// eslint-disable-next-line + type SecondParameter any> = T extends ( + config: any, + args: infer P, +) => any + ? P + : never;\n\n` + : '' +} + ${!noFunction ? `export const ${title} = () => {\n` : ''}`; + +export const generateAxiosFooter: ClientFooterBuilder = ({ + operationNames, + title, + noFunction, + hasMutator, + hasAwaitedType, +}) => { + let footer = ''; + + if (!noFunction) { + footer += `return {${operationNames.join(',')}}};\n`; + } + + if (hasMutator && !hasAwaitedType) { + footer += `\ntype AwaitedInput = PromiseLike | T;\n + type Awaited = O extends AwaitedInput ? T : never; +\n`; + } + + operationNames.forEach((operationName) => { + if (returnTypesToWrite.has(operationName)) { + const func = returnTypesToWrite.get(operationName)!; + footer += func(!noFunction ? title : undefined) + '\n'; + } + }); + + return footer; +}; + +export const generateAxios: ClientBuilder = ( + verbOptions: GeneratorVerbOptions, + options: GeneratorOptions, +) => { + const imports = generateVerbImports(verbOptions); + const implementation = generateAxiosImplementation(verbOptions, options); + + return { implementation, imports }; +}; + +export const generateAxiosFunctions: ClientBuilder = ( + verbOptions, + options, + outputClient, +) => { + const { implementation, imports } = generateAxios( + verbOptions, + options, + outputClient, + ); + + return { + implementation: 'export ' + implementation, + imports, + }; +}; + +const axiosClientBuilder: ClientGeneratorsBuilder = { + client: generateAxios, + header: generateAxiosHeader, + dependencies: getAxiosDependencies, + footer: generateAxiosFooter, + title: generateAxiosTitle, +}; + +const axiosFunctionsClientBuilder: ClientGeneratorsBuilder = { + client: generateAxiosFunctions, + header: (options) => generateAxiosHeader({ ...options, noFunction: true }), + dependencies: getAxiosDependencies, + footer: (options) => generateAxiosFooter({ ...options, noFunction: true }), + title: generateAxiosTitle, +}; + +export const builder = ({ type }: { type: 'axios' | 'axios-functions' }) => { + switch (type) { + case 'axios': + return axiosClientBuilder; + case 'axios-functions': + return axiosFunctionsClientBuilder; + } +}; + +export default builder; diff --git a/packages/axios/tsconfig.json b/packages/axios/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/axios/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..71f113e24 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,53 @@ +{ + "name": "@orval/core", + "version": "6.11.0-alpha.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts", + "test": "vitest --global test.ts" + }, + "devDependencies": { + "@types/chalk": "^2.2.0", + "@types/debug": "^4.1.7", + "@types/fs-extra": "^9.0.13", + "@types/inquirer": "^8.2.2", + "@types/lodash.get": "^4.4.7", + "@types/lodash.omit": "^4.5.7", + "@types/lodash.uniq": "^4.5.7", + "@types/lodash.uniqby": "^4.7.7", + "@types/lodash.uniqwith": "^4.5.7", + "@types/micromatch": "^4.0.2", + "@types/validator": "^13.7.5" + }, + "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", + "acorn": "^8.8.0", + "ajv": "^8.11.0", + "chalk": "^4.1.2", + "compare-versions": "^4.1.3", + "debug": "^4.3.4", + "esbuild": "^0.15.3", + "esutils": "2.0.3", + "fs-extra": "^10.1.0", + "globby": "11.1.0", + "ibm-openapi-validator": "^0.88.0", + "lodash.get": "^4.4.2", + "lodash.omit": "^4.5.0", + "lodash.uniq": "^4.5.0", + "lodash.uniqby": "^4.7.0", + "lodash.uniqwith": "^4.5.0", + "micromatch": "^4.0.5", + "openapi3-ts": "^3.0.0", + "swagger2openapi": "^7.0.8", + "upath": "^2.0.1", + "url": "^0.11.0", + "validator": "^13.7.0" + } +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 000000000..ff65cf8a8 --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1,30 @@ +import { Verbs } from './types'; + +export const generalJSTypes = [ + 'number', + 'string', + 'null', + 'unknown', + 'undefined', + 'object', + 'blob', +]; + +export const generalJSTypesWithArray = generalJSTypes.reduce( + (acc, type) => { + acc.push(type, `Array<${type}>`, `${type}[]`); + + return acc; + }, + [], +); + +export const VERBS_WITH_BODY = [ + Verbs.POST, + Verbs.PUT, + Verbs.PATCH, + Verbs.DELETE, +]; + +export const URL_REGEX = + /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/; diff --git a/packages/core/src/generators/component-definition.ts b/packages/core/src/generators/component-definition.ts new file mode 100644 index 000000000..dfa10a986 --- /dev/null +++ b/packages/core/src/generators/component-definition.ts @@ -0,0 +1,67 @@ +import isEmpty from 'lodash/isEmpty'; +import { + ComponentsObject, + ReferenceObject, + RequestBodyObject, + ResponseObject, +} from 'openapi3-ts'; +import { getResReqTypes } from '../getters'; +import { ContextSpecs, GeneratorSchema } from '../types'; +import { jsDoc, pascal, sanitize } from '../utils'; + +export const generateComponentDefinition = ( + responses: + | ComponentsObject['responses'] + | ComponentsObject['requestBodies'] = {}, + context: ContextSpecs, + suffix: string, +): GeneratorSchema[] => { + if (isEmpty(responses)) { + return []; + } + + return Object.entries(responses).reduce( + ( + acc, + [name, response]: [ + string, + ReferenceObject | RequestBodyObject | ResponseObject, + ], + ) => { + const allResponseTypes = getResReqTypes( + [[suffix, response]], + name, + context, + 'void', + ); + + const imports = allResponseTypes.flatMap(({ imports }) => imports); + const schemas = allResponseTypes.flatMap(({ schemas }) => schemas); + + const type = allResponseTypes.map(({ value }) => value).join(' | '); + + const modelName = sanitize(`${pascal(name)}${suffix}`, { + underscore: '_', + whitespace: '_', + dash: true, + es5keyword: true, + es5IdentifierName: true, + }); + const doc = jsDoc(response as ResponseObject | RequestBodyObject); + const model = `${doc}export type ${modelName} = ${type || 'unknown'};\n`; + + acc.push(...schemas); + + if (modelName !== type) { + acc.push({ + name: modelName, + model, + imports, + }); + } + + return acc; + }, + [] as GeneratorSchema[], + ); +}; diff --git a/packages/core/src/generators/imports.ts b/packages/core/src/generators/imports.ts new file mode 100644 index 000000000..c54976ca7 --- /dev/null +++ b/packages/core/src/generators/imports.ts @@ -0,0 +1,283 @@ +import uniq from 'lodash.uniq'; +import uniqWith from 'lodash.uniqwith'; +import { join } from 'upath'; +import { + GeneratorImport, + GeneratorMutator, + GeneratorVerbOptions, +} from '../types'; +import { camel } from '../utils'; + +export const generateImports = ({ + imports = [], + target, + isRootKey, + specsName, +}: { + imports: GeneratorImport[]; + target: string; + isRootKey: boolean; + specsName: Record; +}) => { + if (!imports.length) { + return ''; + } + + return uniqWith( + imports, + (a, b) => + a.name === b.name && a.default === b.default && a.specKey === b.specKey, + ) + .sort() + .map(({ specKey, name, values, alias }) => { + if (specKey) { + const path = specKey !== target ? specsName[specKey] : ''; + + if (!isRootKey && specKey) { + return `import ${!values ? 'type ' : ''}{ ${name}${ + alias ? ` as ${alias}` : '' + } } from \'../${join(path, camel(name))}\';`; + } + + return `import ${!values ? 'type ' : ''}{ ${name}${ + alias ? ` as ${alias}` : '' + } } from \'./${join(path, camel(name))}\';`; + } + + return `import ${!values ? 'type ' : ''}{ ${name}${ + alias ? ` as ${alias}` : '' + } } from \'./${camel(name)}\';`; + }) + .join('\n'); +}; + +export const generateMutatorImports = ({ + mutators, + implementation, + oneMore, +}: { + mutators: GeneratorMutator[]; + implementation?: string; + oneMore?: boolean; +}) => { + const imports = uniqWith( + mutators, + (a, b) => a.name === b.name && a.default === b.default, + ) + .map((mutator) => { + const path = `${oneMore ? '../' : ''}${mutator.path}`; + const importDefault = mutator.default + ? mutator.name + : `{ ${mutator.name} }`; + + let dep = `import ${importDefault} from '${path}'`; + + if (implementation && (mutator.hasErrorType || mutator.bodyTypeName)) { + let errorImportName = ''; + if ( + mutator.hasErrorType && + implementation.includes(mutator.errorTypeName) + ) { + errorImportName = mutator.default + ? `ErrorType as ${mutator.errorTypeName}` + : mutator.errorTypeName; + } + + let bodyImportName = ''; + if ( + mutator.bodyTypeName && + implementation.includes(mutator.bodyTypeName) + ) { + bodyImportName = mutator.default + ? `BodyType as ${mutator.bodyTypeName}` + : mutator.bodyTypeName; + } + + if (bodyImportName || errorImportName) { + dep += '\n'; + dep += `import type { ${errorImportName}${ + errorImportName && bodyImportName ? ', ' : '' + }${bodyImportName} } from '${path}'`; + } + } + + return dep; + }) + .join('\n'); + + return imports ? imports + '\n' : ''; +}; + +const generateDependency = ({ + deps, + isAllowSyntheticDefaultImports, + dependency, + specsName, + key, + onlyTypes, +}: { + key: string; + deps: GeneratorImport[]; + dependency: string; + specsName: Record; + isAllowSyntheticDefaultImports: boolean; + onlyTypes: boolean; +}) => { + const defaultDep = deps.find( + (e) => + e.default && + (isAllowSyntheticDefaultImports || !e.syntheticDefaultImport), + ); + const syntheticDefaultImportDep = !isAllowSyntheticDefaultImports + ? deps.find((e) => e.syntheticDefaultImport) + : undefined; + + const depsString = uniq( + deps + .filter((e) => !e.default && !e.syntheticDefaultImport) + .map(({ name, alias }) => (alias ? `${name} as ${alias}` : name)), + ).join(',\n '); + + let importString = ''; + + const syntheticDefaultImport = syntheticDefaultImportDep + ? `import * as ${syntheticDefaultImportDep.name} from '${dependency}';` + : ''; + + if (syntheticDefaultImport) { + if (deps.length === 1) { + return syntheticDefaultImport; + } + importString += `${syntheticDefaultImport}\n`; + } + + importString += `import ${onlyTypes ? 'type ' : ''}${ + defaultDep ? `${defaultDep.name}${depsString ? ',' : ''}` : '' + }${depsString ? `{\n ${depsString}\n}` : ''} from '${dependency}${ + key !== 'default' && specsName[key] ? `/${specsName[key]}` : '' + }'`; + + return importString; +}; + +export const addDependency = ({ + implementation, + exports, + dependency, + specsName, + hasSchemaDir, + isAllowSyntheticDefaultImports, +}: { + implementation: string; + exports: GeneratorImport[]; + dependency: string; + specsName: Record; + hasSchemaDir: boolean; + isAllowSyntheticDefaultImports: boolean; +}) => { + const toAdds = exports.filter((e) => + implementation.includes(e.alias || e.name), + ); + + if (!toAdds.length) { + return undefined; + } + + const groupedBySpecKey = toAdds.reduce< + Record + >((acc, dep) => { + const key = hasSchemaDir && dep.specKey ? dep.specKey : 'default'; + + if ( + dep.values && + (isAllowSyntheticDefaultImports || !dep.syntheticDefaultImport) + ) { + acc[key] = { + ...acc[key], + values: [...(acc[key]?.values ?? []), dep], + }; + + return acc; + } + + acc[key] = { + ...acc[key], + types: [...(acc[key]?.types ?? []), dep], + }; + + return acc; + }, {}); + + return Object.entries(groupedBySpecKey) + .map(([key, { values, types }]) => { + let dep = ''; + + if (values) { + dep += generateDependency({ + deps: values, + isAllowSyntheticDefaultImports, + dependency, + specsName, + key, + onlyTypes: false, + }); + } + + if (types) { + if (values) { + dep += '\n'; + } + dep += generateDependency({ + deps: types, + isAllowSyntheticDefaultImports, + dependency, + specsName, + key, + onlyTypes: true, + }); + } + + return dep; + }) + .join('\n'); +}; + +export const generateDependencyImports = ( + implementation: string, + imports: { + exports: GeneratorImport[]; + dependency: string; + }[], + specsName: Record, + hasSchemaDir: boolean, + isAllowSyntheticDefaultImports: boolean, +): string => { + const dependencies = imports + .map((dep) => + addDependency({ + ...dep, + implementation, + specsName, + hasSchemaDir, + isAllowSyntheticDefaultImports, + }), + ) + .filter(Boolean) + .join('\n'); + + return dependencies ? dependencies + '\n' : ''; +}; + +export const generateVerbImports = ({ + response, + body, + queryParams, + headers, + params, +}: GeneratorVerbOptions): GeneratorImport[] => [ + ...response.imports, + ...body.imports, + ...(queryParams ? [{ name: queryParams.schema.name }] : []), + ...(headers ? [{ name: headers.schema.name }] : []), + ...params.flatMap(({ imports }) => imports), +]; diff --git a/packages/core/src/generators/index.ts b/packages/core/src/generators/index.ts new file mode 100644 index 000000000..28f312d75 --- /dev/null +++ b/packages/core/src/generators/index.ts @@ -0,0 +1,8 @@ +export * from './component-definition'; +export * from './imports'; +export * from './models-inline'; +export * from './mutator'; +export * from './options'; +export * from './parameter-definition'; +export * from './schema-definition'; +export * from './verbs-options'; diff --git a/packages/core/src/generators/interface.ts b/packages/core/src/generators/interface.ts new file mode 100644 index 000000000..d6f110f2d --- /dev/null +++ b/packages/core/src/generators/interface.ts @@ -0,0 +1,67 @@ +import { SchemaObject } from 'openapi3-ts'; +import { generalJSTypesWithArray } from '../constants'; +import { getScalar } from '../getters'; +import { ContextSpecs } from '../types'; +import { jsDoc } from '../utils'; + +/** + * Generate the interface string + * A eslint|tslint comment is insert if the resulted object is empty + * + * @param name interface name + * @param schema + */ +export const generateInterface = ({ + name, + schema, + context, + suffix, +}: { + name: string; + schema: SchemaObject; + context: ContextSpecs; + suffix: string; +}) => { + const scalar = getScalar({ + item: schema, + name, + context, + }); + const isEmptyObject = scalar.value === '{}'; + + let model = ''; + + model += jsDoc(schema); + + if (isEmptyObject) { + if (context.tslint) { + model += '// tslint:disable-next-line:no-empty-interface\n'; + } else { + model += + '// eslint-disable-next-line @typescript-eslint/no-empty-interface\n'; + } + } + + if ( + !generalJSTypesWithArray.includes(scalar.value) && + !context?.override?.useTypeOverInterfaces + ) { + model += `export interface ${name} ${scalar.value}\n`; + } else { + model += `export type ${name} = ${scalar.value};\n`; + } + + // Filter out imports that refer to the type defined in current file (OpenAPI recursive schema definitions) + const externalModulesImportsOnly = scalar.imports.filter( + (importName) => importName.name !== name, + ); + + return [ + ...scalar.schemas, + { + name, + model, + imports: externalModulesImportsOnly, + }, + ]; +}; diff --git a/packages/core/src/generators/models-inline.ts b/packages/core/src/generators/models-inline.ts new file mode 100644 index 000000000..1ec05392b --- /dev/null +++ b/packages/core/src/generators/models-inline.ts @@ -0,0 +1,17 @@ +import { GeneratorSchema } from '../types'; + +export const generateModelInline = (acc: string, model: string): string => + acc + `${model}\n`; + +export const generateModelsInline = ( + obj: Record, +): string => { + const schemas = Object.values(obj) + .flatMap((it) => it) + .sort((a, b) => (a.imports.some((i) => i.name === b.name) ? 1 : -1)); + + return schemas.reduce( + (acc, { model }) => generateModelInline(acc, model), + '', + ); +}; diff --git a/packages/core/src/generators/mutator.ts b/packages/core/src/generators/mutator.ts new file mode 100644 index 000000000..3261863cc --- /dev/null +++ b/packages/core/src/generators/mutator.ts @@ -0,0 +1,262 @@ +import { Parser } from 'acorn'; +import chalk from 'chalk'; +import { readFile } from 'fs-extra'; +import { + GeneratorMutator, + GeneratorMutatorParsingInfo, + NormalizedMutator, + Tsconfig, +} from '../types'; +import { + createLogger, + getFileInfo, + loadFile, + pascal, + relativeSafe, +} from '../utils'; + +export const BODY_TYPE_NAME = 'BodyType'; + +const getImport = (output: string, mutator: NormalizedMutator) => { + const outputFileInfo = getFileInfo(output); + const mutatorFileInfo = getFileInfo(mutator.path); + const { pathWithoutExtension } = getFileInfo( + relativeSafe(outputFileInfo.dirname, mutatorFileInfo.path), + ); + + return pathWithoutExtension; +}; + +export const generateMutator = async ({ + output, + mutator, + name, + workspace, + tsconfig, +}: { + output?: string; + mutator?: NormalizedMutator; + name: string; + workspace: string; + tsconfig?: Tsconfig; +}): Promise => { + if (!mutator || !output) { + return; + } + const isDefault = mutator.default; + const importName = mutator.name ? mutator.name : `${name}Mutator`; + const importPath = mutator.path; + + const rawFile = await readFile(importPath, 'utf8'); + + const hasErrorType = + rawFile.includes('export type ErrorType') || + rawFile.includes('export interface ErrorType'); + + const hasBodyType = + rawFile.includes(`export type ${BODY_TYPE_NAME}`) || + rawFile.includes(`export interface ${BODY_TYPE_NAME}`); + + const errorTypeName = !mutator.default + ? 'ErrorType' + : `${pascal(name)}ErrorType`; + + const bodyTypeName = !mutator.default + ? BODY_TYPE_NAME + : `${pascal(name)}${BODY_TYPE_NAME}`; + + const { file, cached } = await loadFile(importPath, { + isDefault: false, + root: workspace, + alias: mutator.alias, + tsconfig, + load: false, + }); + + if (file) { + const mutatorInfoName = isDefault ? 'default' : mutator.name!; + const mutatorInfo = parseFile(file, mutatorInfoName); + + if (!mutatorInfo) { + createLogger().error( + chalk.red( + `Your mutator file doesn't have the ${mutatorInfoName} exported function`, + ), + ); + process.exit(1); + } + + const path = getImport(output, mutator); + + const isHook = mutator.name + ? !!mutator.name.startsWith('use') && !mutatorInfo.numberOfParams + : !mutatorInfo.numberOfParams; + + return { + name: mutator.name || !isHook ? importName : `use${pascal(importName)}`, + path, + default: isDefault, + hasErrorType, + errorTypeName, + hasSecondArg: !isHook + ? mutatorInfo.numberOfParams > 1 + : mutatorInfo.returnNumberOfParams! > 1, + hasThirdArg: mutatorInfo.numberOfParams > 2, + isHook, + ...(hasBodyType ? { bodyTypeName } : {}), + }; + } else { + const path = getImport(output, mutator); + + if (!cached) { + createLogger().warn( + chalk.yellow(`Failed to parse provided mutator function`), + ); + } + + return { + name: importName, + path, + default: isDefault, + hasSecondArg: false, + hasThirdArg: false, + isHook: false, + hasErrorType, + errorTypeName, + ...(hasBodyType ? { bodyTypeName } : {}), + }; + } +}; + +const parseFile = ( + file: string, + name: string, +): GeneratorMutatorParsingInfo | undefined => { + try { + const ast = Parser.parse(file, { ecmaVersion: 6 }) as any; + + const node = ast?.body?.find((childNode: any) => { + if (childNode.type === 'ExpressionStatement') { + if ( + childNode.expression.arguments?.[1]?.properties?.some( + (p: any) => p.key?.name === name, + ) + ) { + return true; + } + + if (childNode.expression.left?.property?.name === name) { + return true; + } + + return childNode.expression.right?.properties?.some( + (p: any) => p.key.name === name, + ); + } + }); + + if (!node) { + return; + } + + if (node.expression.type === 'AssignmentExpression') { + if ( + node.expression.right.type === 'FunctionExpression' || + node.expression.right.type === 'ArrowFunctionExpression' + ) { + return { + numberOfParams: node.expression.right.params.length, + }; + } + + if (node.expression.right.name) { + return parseFunction(ast, node.expression.right.name); + } + + const property = node.expression.right?.properties.find( + (p: any) => p.key.name === name, + ); + + if (property.value.name) { + return parseFunction(ast, property.value.name); + } + + if ( + property.value.type === 'FunctionExpression' || + property.value.type === 'ArrowFunctionExpression' + ) { + return { + numberOfParams: property.value.params.length, + }; + } + + return; + } + + const property = node.expression.arguments[1].properties.find( + (p: any) => p.key?.name === name, + ); + + return parseFunction(ast, property.value.body.name); + } catch (e) { + return; + } +}; + +const parseFunction = ( + ast: any, + name: string, +): GeneratorMutatorParsingInfo | undefined => { + const node = ast?.body?.find((childNode: any) => { + if (childNode.type === 'VariableDeclaration') { + return childNode.declarations.find((d: any) => d.id.name === name); + } + if ( + childNode.type === 'FunctionDeclaration' && + childNode.id.name === name + ) { + return childNode; + } + }); + + if (!node) { + return; + } + + if (node.type === 'FunctionDeclaration') { + const returnStatement = node.body?.body?.find( + (b: any) => b.type === 'ReturnStatement', + ); + + if (returnStatement?.argument?.params) { + return { + numberOfParams: node.params.length, + returnNumberOfParams: returnStatement.argument.params.length, + }; + } + return { + numberOfParams: node.params.length, + }; + } + + const declaration = node.declarations.find((d: any) => d.id.name === name); + + if (declaration.init.name) { + return parseFunction(ast, declaration.init.name); + } + + const returnStatement = declaration.init.body?.body?.find( + (b: any) => b.type === 'ReturnStatement', + ); + + if (returnStatement?.argument?.params) { + return { + numberOfParams: declaration.init.params.length, + returnNumberOfParams: returnStatement.argument.params.length, + }; + } + + return { + numberOfParams: declaration.init.params.length, + }; +}; diff --git a/packages/core/src/generators/options.ts b/packages/core/src/generators/options.ts new file mode 100644 index 000000000..85cf1a916 --- /dev/null +++ b/packages/core/src/generators/options.ts @@ -0,0 +1,308 @@ +import { VERBS_WITH_BODY } from '../constants'; +import { + GeneratorMutator, + GeneratorSchema, + GetterBody, + GetterQueryParam, + GetterResponse, + Verbs, +} from '../types'; +import { isObject, stringify } from '../utils'; + +export const generateBodyOptions = ( + body: GetterBody, + isFormData: boolean, + isFormUrlEncoded: boolean, +) => { + if (isFormData && body.formData) { + return '\n formData,'; + } + + if (isFormUrlEncoded && body.formUrlEncoded) { + return '\n formUrlEncoded,'; + } + + if (body.implementation) { + return `\n ${body.implementation},`; + } + + return ''; +}; + +export const generateAxiosOptions = ({ + response, + isExactOptionalPropertyTypes, + queryParams, + headers, + requestOptions, + hasSignal, +}: { + response: GetterResponse; + isExactOptionalPropertyTypes: boolean; + queryParams?: GeneratorSchema; + headers?: GeneratorSchema; + requestOptions?: object | boolean; + hasSignal: boolean; +}) => { + const isRequestOptions = requestOptions !== false; + if (!queryParams && !headers && !response.isBlob) { + if (isRequestOptions) { + return 'options'; + } + if (hasSignal) { + return !isExactOptionalPropertyTypes + ? 'signal' + : '...(signal ? { signal } : {})'; + } + return ''; + } + + let value = ''; + + if (!isRequestOptions) { + if (queryParams) { + value += '\n params,'; + } + + if (headers) { + value += '\n headers,'; + } + + if (hasSignal) { + value += !isExactOptionalPropertyTypes + ? '\n signal,' + : '\n ...(signal ? { signal } : {}),'; + } + } + + if ( + response.isBlob && + (!isObject(requestOptions) || + !requestOptions.hasOwnProperty('responseType')) + ) { + value += `\n responseType: 'blob',`; + } + + if (isObject(requestOptions)) { + value += `\n ${stringify(requestOptions)?.slice(1, -1)}`; + } + + if (isRequestOptions) { + value += '\n ...options,'; + + if (queryParams) { + value += '\n params: {...params, ...options?.params},'; + } + + if (headers) { + value += '\n headers: {...headers, ...options?.headers},'; + } + } + + return value; +}; + +export const generateOptions = ({ + route, + body, + headers, + queryParams, + response, + verb, + requestOptions, + isFormData, + isFormUrlEncoded, + isAngular, + isExactOptionalPropertyTypes, + hasSignal, +}: { + route: string; + body: GetterBody; + headers?: GetterQueryParam; + queryParams?: GetterQueryParam; + response: GetterResponse; + verb: Verbs; + requestOptions?: object | boolean; + isFormData: boolean; + isFormUrlEncoded: boolean; + isAngular?: boolean; + isExactOptionalPropertyTypes: boolean; + hasSignal: boolean; +}) => { + const isBodyVerb = VERBS_WITH_BODY.includes(verb); + const bodyOptions = isBodyVerb + ? generateBodyOptions(body, isFormData, isFormUrlEncoded) + : ''; + + const axiosOptions = generateAxiosOptions({ + response, + queryParams: queryParams?.schema, + headers: headers?.schema, + requestOptions, + isExactOptionalPropertyTypes, + hasSignal, + }); + + const options = axiosOptions ? `{${axiosOptions}}` : ''; + + if (verb === Verbs.DELETE) { + if (!bodyOptions) { + return `\n \`${route}\`,${ + axiosOptions === 'options' ? axiosOptions : options + }\n `; + } + + return `\n \`${route}\`,{${ + isAngular ? 'body' : 'data' + }:${bodyOptions} ${ + axiosOptions === 'options' ? `...${axiosOptions}` : axiosOptions + }}\n `; + } + + return `\n \`${route}\`,${ + isBodyVerb ? bodyOptions || 'undefined,' : '' + }${axiosOptions === 'options' ? axiosOptions : options}\n `; +}; + +export const generateBodyMutatorConfig = ( + body: GetterBody, + isFormData: boolean, + isFormUrlEncoded: boolean, +) => { + if (isFormData && body.formData) { + return ',\n data: formData'; + } + + if (isFormUrlEncoded && body.formUrlEncoded) { + return ',\n data: formUrlEncoded'; + } + + if (body.implementation) { + return `,\n data: ${body.implementation}`; + } + + return ''; +}; + +export const generateQueryParamsAxiosConfig = ( + response: GetterResponse, + queryParams?: GetterQueryParam, +) => { + if (!queryParams && !response.isBlob) { + return ''; + } + + let value = ''; + + if (queryParams) { + value += ',\n params'; + } + + if (response.isBlob) { + value += `,\n responseType: 'blob'`; + } + + return value; +}; + +export const generateMutatorConfig = ({ + route, + body, + headers, + queryParams, + response, + verb, + isFormData, + isFormUrlEncoded, + isBodyVerb, + hasSignal, + isExactOptionalPropertyTypes, +}: { + route: string; + body: GetterBody; + headers?: GetterQueryParam; + queryParams?: GetterQueryParam; + response: GetterResponse; + verb: Verbs; + isFormData: boolean; + isFormUrlEncoded: boolean; + isBodyVerb: boolean; + hasSignal: boolean; + isExactOptionalPropertyTypes: boolean; +}) => { + const bodyOptions = isBodyVerb + ? generateBodyMutatorConfig(body, isFormData, isFormUrlEncoded) + : ''; + + const queryParamsOptions = generateQueryParamsAxiosConfig( + response, + queryParams, + ); + + const headerOptions = body.contentType + ? `,\n headers: {'Content-Type': '${body.contentType}', ${ + headers ? '...headers' : '' + }}` + : headers + ? ',\n headers' + : ''; + + return `{url: \`${route}\`, method: '${verb}'${headerOptions}${bodyOptions}${queryParamsOptions}${ + !isBodyVerb && hasSignal + ? `, ${ + isExactOptionalPropertyTypes + ? '...(signal ? { signal }: {})' + : 'signal' + }` + : '' + }\n }`; +}; + +export const generateMutatorRequestOptions = ( + requestOptions: boolean | object | undefined, + hasSecondArgument: boolean, +) => { + if (!hasSecondArgument) { + return isObject(requestOptions) + ? stringify(requestOptions)?.slice(1, -1) + : ''; + } + + if (isObject(requestOptions)) { + return `{${stringify(requestOptions)?.slice(1, -1)} ...options}`; + } + + return 'options'; +}; + +export const generateFormDataAndUrlEncodedFunction = ({ + body, + formData, + formUrlEncoded, + isFormData, + isFormUrlEncoded, +}: { + body: GetterBody; + formData?: GeneratorMutator; + formUrlEncoded?: GeneratorMutator; + isFormData: boolean; + isFormUrlEncoded: boolean; +}) => { + if (isFormData && body.formData) { + if (formData) { + return `const formData = ${formData.name}(${body.implementation})`; + } + + return body.formData; + } + + if (isFormUrlEncoded && body.formUrlEncoded) { + if (formUrlEncoded) { + return `const formUrlEncoded = ${formUrlEncoded.name}(${body.implementation})`; + } + + return body.formUrlEncoded; + } + + return ''; +}; diff --git a/packages/core/src/generators/parameter-definition.ts b/packages/core/src/generators/parameter-definition.ts new file mode 100644 index 000000000..90163797f --- /dev/null +++ b/packages/core/src/generators/parameter-definition.ts @@ -0,0 +1,75 @@ +import { ComponentsObject, ParameterObject } from 'openapi3-ts'; +import { resolveObject, resolveRef } from '../resolvers'; +import { ContextSpecs, GeneratorSchema } from '../types'; +import { jsDoc, pascal, sanitize } from '../utils'; + +export const generateParameterDefinition = ( + parameters: ComponentsObject['parameters'] = {}, + context: ContextSpecs, + suffix: string, +): GeneratorSchema[] => { + return Object.entries(parameters).reduce( + (acc, [parameterName, parameter]) => { + const modelName = sanitize(`${pascal(parameterName)}${suffix}`, { + underscore: '_', + whitespace: '_', + dash: true, + es5keyword: true, + es5IdentifierName: true, + }); + const { schema, imports } = resolveRef( + parameter, + context, + ); + + if (schema.in !== 'query') { + return acc; + } + + if (!schema.schema || imports.length) { + acc.push({ + name: modelName, + imports: imports.length + ? [ + { + name: imports[0].name, + specKey: imports[0].specKey, + schemaName: imports[0].schemaName, + }, + ] + : [], + model: `export type ${modelName} = ${ + imports.length ? imports[0].name : 'unknown' + };\n`, + }); + + return acc; + } + + const resolvedObject = resolveObject({ + schema: schema.schema, + propName: modelName, + context, + }); + + const doc = jsDoc(parameter as ParameterObject); + + const model = `${doc}export type ${modelName} = ${ + resolvedObject.value || 'unknown' + };\n`; + + acc.push(...resolvedObject.schemas); + + if (modelName !== resolvedObject.value) { + acc.push({ + name: modelName, + model, + imports: resolvedObject.imports, + }); + } + + return acc; + }, + [] as GeneratorSchema[], + ); +}; diff --git a/packages/core/src/generators/schema-definition.ts b/packages/core/src/generators/schema-definition.ts new file mode 100644 index 000000000..f200b87b7 --- /dev/null +++ b/packages/core/src/generators/schema-definition.ts @@ -0,0 +1,104 @@ +import isEmpty from 'lodash/isEmpty'; +import { SchemasObject } from 'openapi3-ts'; +import { getEnum, resolveDiscriminators } from '../getters'; +import { resolveValue } from '../resolvers'; +import { ContextSpecs, GeneratorSchema } from '../types'; +import { getSpecName, isReference, jsDoc, pascal, sanitize } from '../utils'; +import { generateInterface } from './interface'; + +/** + * Extract all types from #/components/schemas + * + * @param schemas + */ +export const generateSchemasDefinition = ( + schemas: SchemasObject = {}, + context: ContextSpecs, + suffix: string, +): GeneratorSchema[] => { + if (isEmpty(schemas)) { + return []; + } + + const transformedSchemas = resolveDiscriminators(schemas, context); + + const models = Object.entries(transformedSchemas).reduce( + (acc, [name, schema]) => { + const schemaName = sanitize(`${pascal(name)}${suffix}`, { + underscore: '_', + whitespace: '_', + dash: true, + es5keyword: true, + es5IdentifierName: true, + }); + if ( + (!schema.type || schema.type === 'object') && + !schema.allOf && + !schema.oneOf && + !schema.anyOf && + !isReference(schema) && + !schema.nullable + ) { + acc.push( + ...generateInterface({ + name: schemaName, + schema, + context, + suffix, + }), + ); + + return acc; + } else { + const resolvedValue = resolveValue({ + schema, + name: schemaName, + context, + }); + + let output = ''; + + let imports = resolvedValue.imports; + + output += jsDoc(schema); + + if (resolvedValue.isEnum && !resolvedValue.isRef) { + output += getEnum(resolvedValue.value, schemaName); + } else if (schemaName === resolvedValue.value && resolvedValue.isRef) { + const imp = resolvedValue.imports.find( + (imp) => imp.name === schemaName, + ); + + if (!imp) { + output += `export type ${schemaName} = ${resolvedValue.value};\n`; + } else { + const alias = imp?.specKey + ? `${pascal(getSpecName(imp.specKey, context.specKey))}${ + resolvedValue.value + }` + : `${resolvedValue.value}Bis`; + + output += `export type ${schemaName} = ${alias};\n`; + + imports = imports.map((imp) => + imp.name === schemaName ? { ...imp, alias } : imp, + ); + } + } else { + output += `export type ${schemaName} = ${resolvedValue.value};\n`; + } + + acc.push(...resolvedValue.schemas, { + name: schemaName, + model: output, + imports, + }); + + return acc; + } + }, + [] as GeneratorSchema[], + ); + + return models; +}; diff --git a/packages/core/src/generators/verbs-options.ts b/packages/core/src/generators/verbs-options.ts new file mode 100644 index 000000000..026c850dc --- /dev/null +++ b/packages/core/src/generators/verbs-options.ts @@ -0,0 +1,219 @@ +import { + ComponentsObject, + OperationObject, + ParameterObject, + PathItemObject, + ReferenceObject, +} from 'openapi3-ts'; +import { + getBody, + getOperationId, + getParameters, + getParams, + getProps, + getQueryParams, + getResponse, +} from '../getters'; +import { + ContextSpecs, + GeneratorVerbOptions, + GeneratorVerbsOptions, + NormalizedOperationOptions, + NormalizedOutputOptions, + NormalizedOverrideOutput, + Verbs, +} from '../types'; +import { + asyncReduce, + camel, + dynamicImport, + isObject, + isString, + isVerb, + jsDoc, + mergeDeep, + sanitize, +} from '../utils'; +import { generateMutator } from './mutator'; + +const generateVerbOptions = async ({ + verb, + output, + operation, + route, + verbParameters = [], + context, +}: { + verb: Verbs; + output: NormalizedOutputOptions; + operation: OperationObject; + route: string; + verbParameters?: Array; + components?: ComponentsObject; + context: ContextSpecs; +}): Promise => { + const { + responses, + requestBody, + parameters: operationParameters, + tags = [], + deprecated, + description, + summary, + } = operation; + + const operationId = getOperationId(operation, route, verb); + const overrideOperation = output.override.operations[operation.operationId!]; + const overrideTag = Object.entries(output.override.tags).reduce( + (acc, [tag, options]) => + tags.includes(tag) ? mergeDeep(acc, options) : acc, + {} as NormalizedOperationOptions, + ); + + const override: NormalizedOverrideOutput = { + ...output.override, + ...overrideTag, + ...overrideOperation, + }; + + const overrideOperationName = + overrideOperation?.operationName || output.override?.operationName; + const overriddenOperationName = overrideOperationName + ? overrideOperationName(operation, route, verb) + : camel(operationId); + const operationName = sanitize(overriddenOperationName, { es5keyword: true }); + + const response = getResponse({ + responses, + operationName, + context, + contentType: override.contentType, + }); + + const body = getBody({ + requestBody: requestBody!, + operationName, + context, + contentType: override.contentType, + }); + + const parameters = getParameters({ + parameters: [...verbParameters, ...(operationParameters ?? [])], + context, + }); + + const queryParams = getQueryParams({ + queryParams: parameters.query, + operationName, + context, + }); + + const headers = output.headers + ? await getQueryParams({ + queryParams: parameters.header, + operationName, + context, + suffix: 'headers', + }) + : undefined; + + const params = getParams({ + route, + pathParams: parameters.path, + operationId: operationId!, + context, + }); + + const props = getProps({ body, queryParams, params, headers }); + + const mutator = await generateMutator({ + output: output.target, + name: operationName, + mutator: override?.mutator, + workspace: context.workspace, + tsconfig: context.tsconfig, + }); + + const formData = + isString(override?.formData) || isObject(override?.formData) + ? await generateMutator({ + output: output.target, + name: operationName, + mutator: override.formData, + workspace: context.workspace, + tsconfig: context.tsconfig, + }) + : undefined; + + const formUrlEncoded = + isString(override?.formUrlEncoded) || isObject(override?.formUrlEncoded) + ? await generateMutator({ + output: output.target, + name: operationName, + mutator: override.formUrlEncoded, + workspace: context.workspace, + tsconfig: context.tsconfig, + }) + : undefined; + + const doc = jsDoc({ description, deprecated, summary }); + + const verbOption: GeneratorVerbOptions = { + verb: verb as Verbs, + tags, + summary: operation.summary, + operationId: operationId!, + operationName, + response, + body, + headers, + queryParams, + params, + props, + mutator, + formData, + formUrlEncoded, + override, + doc, + deprecated, + }; + + const transformer = await dynamicImport( + override?.transformer, + context.workspace, + ); + + return transformer ? transformer(verbOption) : verbOption; +}; + +export const generateVerbsOptions = ({ + verbs, + output, + route, + context, +}: { + verbs: PathItemObject; + output: NormalizedOutputOptions; + route: string; + context: ContextSpecs; +}): Promise => + asyncReduce( + Object.entries(verbs), + async (acc, [verb, operation]: [string, OperationObject]) => { + if (isVerb(verb)) { + const verbOptions = await generateVerbOptions({ + verb, + output, + verbParameters: verbs.parameters, + route, + operation, + context, + }); + + acc.push(verbOptions); + } + + return acc; + }, + [] as GeneratorVerbsOptions, + ); diff --git a/packages/core/src/getters/array.ts b/packages/core/src/getters/array.ts new file mode 100644 index 000000000..011cc4ea0 --- /dev/null +++ b/packages/core/src/getters/array.ts @@ -0,0 +1,38 @@ +import { SchemaObject } from 'openapi3-ts'; +import { ContextSpecs, ResolverValue } from '../types'; +import { resolveObject } from '../resolvers/object'; + +/** + * Return the output type from an array + * + * @param item item with type === "array" + */ +export const getArray = ({ + schema, + name, + context, +}: { + schema: SchemaObject; + name?: string; + context: ContextSpecs; +}): ResolverValue => { + if (schema.items) { + const resolvedObject = resolveObject({ + schema: schema.items, + propName: name + 'Item', + context, + }); + return { + value: resolvedObject.value.includes('|') + ? `(${resolvedObject.value})[]` + : `${resolvedObject.value}[]`, + imports: resolvedObject.imports, + schemas: resolvedObject.schemas, + isEnum: false, + type: 'array', + isRef: false, + }; + } else { + throw new Error('All arrays must have an `items` key define'); + } +}; diff --git a/packages/core/src/getters/body.ts b/packages/core/src/getters/body.ts new file mode 100644 index 000000000..cbc029a0e --- /dev/null +++ b/packages/core/src/getters/body.ts @@ -0,0 +1,70 @@ +import { ReferenceObject, RequestBodyObject } from 'openapi3-ts'; +import { generalJSTypesWithArray } from '../constants'; +import { ContextSpecs, OverrideOutputContentType } from '../types'; +import { GetterBody } from '../types'; +import { camel } from '../utils'; +import { getResReqTypes } from './res-req-types'; + +export const getBody = ({ + requestBody, + operationName, + context, + contentType, +}: { + requestBody: ReferenceObject | RequestBodyObject; + operationName: string; + context: ContextSpecs; + contentType?: OverrideOutputContentType; +}): GetterBody => { + const allBodyTypes = getResReqTypes( + [[context.override.components.requestBodies.suffix, requestBody]], + operationName, + context, + ); + + const filteredBodyTypes = contentType + ? allBodyTypes.filter((type) => { + let include = true; + let exclude = false; + + if (contentType.include) { + include = contentType.include.includes(type.contentType); + } + + if (contentType.exclude) { + exclude = contentType.exclude.includes(type.contentType); + } + + return include && !exclude; + }) + : allBodyTypes; + + const imports = filteredBodyTypes.flatMap(({ imports }) => imports); + const schemas = filteredBodyTypes.flatMap(({ schemas }) => schemas); + + const definition = filteredBodyTypes.map(({ value }) => value).join(' | '); + + const implementation = + generalJSTypesWithArray.includes(definition.toLowerCase()) || + filteredBodyTypes.length > 1 + ? camel(operationName) + context.override.components.requestBodies.suffix + : camel(definition); + + return { + definition, + implementation, + imports, + schemas, + ...(filteredBodyTypes.length === 1 + ? { + formData: filteredBodyTypes[0].formData, + formUrlEncoded: filteredBodyTypes[0].formUrlEncoded, + contentType: filteredBodyTypes[0].contentType, + } + : { + formData: '', + formUrlEncoded: '', + contentType: '', + }), + }; +}; diff --git a/packages/core/src/getters/combine.ts b/packages/core/src/getters/combine.ts new file mode 100644 index 000000000..247f09ce0 --- /dev/null +++ b/packages/core/src/getters/combine.ts @@ -0,0 +1,170 @@ +import omit from 'lodash.omit'; +import { SchemaObject } from 'openapi3-ts'; +import { resolveObject } from '../resolvers'; +import { + ContextSpecs, + GeneratorImport, + ResolverValue, + SchemaType, +} from '../types'; +import { getNumberWord, pascal } from '../utils'; +import { getEnumImplementation } from './enum'; +import { getScalar } from './scalar'; + +type CombinedData = Omit< + ResolverValue, + 'isRef' | 'isEnum' | 'value' | 'type' +> & { + values: string[]; + isRef: boolean[]; + isEnum: boolean[]; + types: string[]; +}; + +type Separator = 'allOf' | 'anyOf' | 'oneOf'; + +const combineValues = ({ + resolvedData, + resolvedValue, + separator, +}: { + resolvedData: CombinedData; + resolvedValue?: ResolverValue; + separator: Separator; +}) => { + const isAllEnums = resolvedData.isEnum.every((v) => v); + + if (isAllEnums) { + return `${resolvedData.values.join(` | `)}${ + resolvedValue ? ` | ${resolvedValue.value}` : '' + }`; + } + + if (separator === 'allOf') { + return `${resolvedData.values.join(` & `)}${ + resolvedValue ? ` & ${resolvedValue.value}` : '' + }`; + } + + if (resolvedValue) { + return `(${resolvedData.values.join(` & ${resolvedValue.value}) | (`)} & ${ + resolvedValue.value + })`; + } + + return resolvedData.values.join(' | '); +}; + +export const combineSchemas = ({ + name, + schema, + separator, + context, + nullable, +}: { + name?: string; + schema: SchemaObject; + separator: Separator; + context: ContextSpecs; + nullable: string; +}) => { + const items = schema[separator] ?? []; + + const resolvedData = items.reduce( + (acc, subSchema) => { + let propName = name ? name + pascal(separator) : undefined; + if (propName && acc.schemas.length) { + propName = propName + pascal(getNumberWord(acc.schemas.length + 1)); + } + + const resolvedValue = resolveObject({ + schema: subSchema, + propName, + combined: true, + context, + }); + + acc.values.push(resolvedValue.value); + acc.imports.push(...resolvedValue.imports); + acc.schemas.push(...resolvedValue.schemas); + acc.isEnum.push(resolvedValue.isEnum); + acc.types.push(resolvedValue.type); + acc.isRef.push(resolvedValue.isRef); + + return acc; + }, + { + values: [], + imports: [], + schemas: [], + isEnum: [], // check if only enums + isRef: [], + types: [], + } as CombinedData, + ); + + const isAllEnums = resolvedData.isEnum.every((v) => v); + + let resolvedValue; + + if (schema.properties) { + resolvedValue = getScalar({ item: omit(schema, separator), name, context }); + } + + const value = combineValues({ resolvedData, separator, resolvedValue }); + + if (isAllEnums && name && items.length > 1) { + const newEnum = `\n\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const ${pascal( + name, + )} = ${getCombineEnumValue(resolvedData)}`; + + return { + value: + `typeof ${pascal(name)}[keyof typeof ${pascal(name)}] ${nullable};` + + newEnum, + imports: resolvedData.imports.map((toImport) => ({ + ...toImport, + values: true, + })), + schemas: resolvedData.schemas, + isEnum: false, + type: 'object' as SchemaType, + isRef: false, + }; + } + + return { + value: value + nullable, + imports: resolvedValue + ? [...resolvedData.imports, ...resolvedValue.imports] + : resolvedData.imports, + schemas: resolvedValue + ? [...resolvedData.schemas, ...resolvedValue.schemas] + : resolvedData.schemas, + isEnum: false, + type: 'object' as SchemaType, + isRef: false, + }; +}; + +const getCombineEnumValue = ({ values, isRef, types }: CombinedData) => { + if (values.length === 1) { + if (isRef[0]) { + return values[0]; + } + + return `{${getEnumImplementation(values[0])}} as const`; + } + + const enums = values + .map((e, i) => { + if (isRef[i]) { + return `...${e},`; + } + + return getEnumImplementation(e); + }) + .join(''); + + return `{${enums}} as const`; +}; diff --git a/packages/core/src/getters/discriminators.ts b/packages/core/src/getters/discriminators.ts new file mode 100644 index 000000000..3462eb6f8 --- /dev/null +++ b/packages/core/src/getters/discriminators.ts @@ -0,0 +1,45 @@ +import { SchemasObject } from 'openapi3-ts'; +import { ContextSpecs } from '../types'; +import { getRefInfo } from './ref'; + +export const resolveDiscriminators = ( + schemas: SchemasObject, + context: ContextSpecs, +): SchemasObject => { + const transformedSchemas = { ...schemas }; + + for (const schema of Object.values(transformedSchemas)) { + if (schema.discriminator?.mapping) { + const { mapping, propertyName } = schema.discriminator; + + for (const [mappingKey, mappingValue] of Object.entries(mapping)) { + let subTypeSchema; + + try { + const { name } = getRefInfo(mappingValue, context); + subTypeSchema = transformedSchemas[name]; + } catch (e) { + subTypeSchema = transformedSchemas[mappingValue]; + } + + if (!subTypeSchema) { + continue; + } + + subTypeSchema.properties = { + ...subTypeSchema.properties, + [propertyName]: { + type: 'string', + enum: [mappingKey], + }, + }; + subTypeSchema.required = [ + ...(subTypeSchema.required ?? []), + propertyName, + ]; + } + } + } + + return transformedSchemas; +}; diff --git a/packages/core/src/getters/enum.ts b/packages/core/src/getters/enum.ts new file mode 100644 index 000000000..9a4663905 --- /dev/null +++ b/packages/core/src/getters/enum.ts @@ -0,0 +1,55 @@ +import { keyword } from 'esutils'; +import { sanitize } from '../utils'; + +export const getEnum = (value: string, enumName: string) => { + let enumValue = `export type ${enumName} = typeof ${enumName}[keyof typeof ${enumName}];\n`; + + const implementation = getEnumImplementation(value); + + enumValue += `\n\n`; + + enumValue += '// eslint-disable-next-line @typescript-eslint/no-redeclare\n'; + + enumValue += `export const ${enumName} = {\n${implementation}} as const;\n`; + + return enumValue; +}; + +export const getEnumImplementation = (value: string) => { + return [...new Set(value.split(' | '))].reduce((acc, val) => { + // nullable value shouldn't be in the enum implementation + if (val === 'null') return acc; + + let key = val.startsWith("'") ? val.slice(1, -1) : val; + + const isNumber = !Number.isNaN(Number(key)); + + if (isNumber) { + key = toNumberKey(key); + } + + if (key.length > 1) { + key = sanitize(key, { + whitespace: '_', + underscore: true, + dash: true, + special: true, + }); + } + + return ( + acc + + ` ${keyword.isIdentifierNameES5(key) ? key : `'${key}'`}: ${val},\n` + ); + }, ''); +}; + +const toNumberKey = (value: string) => { + if (value[0] === '-') { + return `NUMBER_MINUS_${value.slice(1)}`; + } + if (value[0] === '+') { + return `NUMBER_PLUS_${value.slice(1)}`; + } + return `NUMBER_${value}`; +}; diff --git a/packages/core/src/getters/index.ts b/packages/core/src/getters/index.ts new file mode 100644 index 000000000..411143437 --- /dev/null +++ b/packages/core/src/getters/index.ts @@ -0,0 +1,17 @@ +export * from './array'; +export * from './body'; +export * from './combine'; +export * from './discriminators'; +export * from './enum'; +export * from './keys'; +export * from './object'; +export * from './operation'; +export * from './parameters'; +export * from './params'; +export * from './props'; +export * from './query-params'; +export * from './ref'; +export * from './res-req-types'; +export * from './response'; +export * from './route'; +export * from './scalar'; diff --git a/packages/core/src/getters/keys.ts b/packages/core/src/getters/keys.ts new file mode 100644 index 000000000..27734c37d --- /dev/null +++ b/packages/core/src/getters/keys.ts @@ -0,0 +1,5 @@ +import { keyword } from 'esutils'; + +export const getKey = (key: string) => { + return keyword.isIdentifierNameES5(key) ? key : `'${key}'`; +}; diff --git a/packages/core/src/getters/object.ts b/packages/core/src/getters/object.ts new file mode 100644 index 000000000..35910faf9 --- /dev/null +++ b/packages/core/src/getters/object.ts @@ -0,0 +1,168 @@ +import { ReferenceObject, SchemaObject } from 'openapi3-ts'; +import { resolveObject, resolveValue } from '../resolvers'; +import { ContextSpecs, ResolverValue, SchemaType } from '../types'; +import { isBoolean, isReference, jsDoc, pascal } from '../utils'; +import { combineSchemas } from './combine'; +import { getKey } from './keys'; +import { getRefInfo } from './ref'; + +/** + * Return the output type from an object + * + * @param item item with type === "object" + */ +export const getObject = ({ + item, + name, + context, + nullable, +}: { + item: SchemaObject; + name?: string; + context: ContextSpecs; + nullable: string; +}): ResolverValue => { + if (isReference(item)) { + const { name, specKey } = getRefInfo(item.$ref, context); + return { + value: name + nullable, + imports: [{ name, specKey }], + schemas: [], + isEnum: false, + type: 'object', + isRef: true, + }; + } + + if (item.allOf || item.oneOf || item.anyOf) { + const separator = item.allOf ? 'allOf' : item.oneOf ? 'oneOf' : 'anyOf'; + + return combineSchemas({ + schema: item, + name, + separator, + context, + nullable, + }); + } + + if (item.type instanceof Array) { + return combineSchemas({ + schema: { anyOf: item.type.map((type) => ({ type })) }, + name, + separator: 'anyOf', + context, + nullable, + }); + } + + if (item.properties && Object.entries(item.properties).length > 0) { + return Object.entries(item.properties).reduce( + ( + acc, + [key, schema]: [string, ReferenceObject | SchemaObject], + index, + arr, + ) => { + const isRequired = ( + Array.isArray(item.required) ? item.required : [] + ).includes(key); + let propName = name ? pascal(name) + pascal(key) : undefined; + + const isNameAlreadyTaken = + !!context.specs[context.target]?.components?.schemas?.[ + propName || '' + ]; + + if (isNameAlreadyTaken) { + propName = propName + 'Property'; + } + + const resolvedValue = resolveObject({ + schema, + propName, + context, + }); + + const isReadOnly = item.readOnly || (schema as SchemaObject).readOnly; + if (!index) { + acc.value += '{'; + } + + const doc = jsDoc(schema as SchemaObject, true); + + acc.imports.push(...resolvedValue.imports); + acc.value += `\n ${doc ? `${doc} ` : ''}${ + isReadOnly ? 'readonly ' : '' + }${getKey(key)}${isRequired ? '' : '?'}: ${resolvedValue.value};`; + acc.schemas.push(...resolvedValue.schemas); + + if (arr.length - 1 === index) { + if (item.additionalProperties) { + if (isBoolean(item.additionalProperties)) { + acc.value += `\n [key: string]: any;\n }`; + } else { + const resolvedValue = resolveValue({ + schema: item.additionalProperties, + name, + context, + }); + acc.value += `\n [key: string]: ${resolvedValue.value};\n}`; + } + } else { + acc.value += '\n}'; + } + + acc.value += nullable; + } + + return acc; + }, + { + imports: [], + schemas: [], + value: '', + isEnum: false, + type: 'object' as SchemaType, + isRef: false, + schema: {}, + } as ResolverValue, + ); + } + + if (item.additionalProperties) { + if (isBoolean(item.additionalProperties)) { + return { + value: `{ [key: string]: any }` + nullable, + imports: [], + schemas: [], + isEnum: false, + type: 'object', + isRef: false, + }; + } + const resolvedValue = resolveValue({ + schema: item.additionalProperties, + name, + context, + }); + return { + value: `{[key: string]: ${resolvedValue.value}}` + nullable, + imports: resolvedValue.imports ?? [], + schemas: resolvedValue.schemas ?? [], + isEnum: false, + type: 'object', + isRef: false, + }; + } + + return { + value: + item.type === 'object' ? '{ [key: string]: any }' : 'unknown' + nullable, + imports: [], + schemas: [], + isEnum: false, + type: 'object', + isRef: false, + }; +}; diff --git a/packages/core/src/getters/operation.test.ts b/packages/core/src/getters/operation.test.ts new file mode 100644 index 000000000..181c8884a --- /dev/null +++ b/packages/core/src/getters/operation.test.ts @@ -0,0 +1,17 @@ +import { OperationObject } from 'openapi3-ts'; +import { getOperationId } from './operation'; + +describe('getOperationId getter', () => { + [ + ['/api/test/{id}', 'ApiTestId'], + ['/api/test/{user_id}', 'ApiTestUserId'], + ['/api/test/{locale}.js', 'ApiTestLocaleJs'], + ['/api/test/i18n-{locale}.js', 'ApiTestI18nLocaleJs'], + ['/api/test/{param1}-{param2}.js', 'ApiTestParam1Param2Js'], + ['/api/test/user{param1}-{param2}.html', 'ApiTestUserparam1Param2Html'], + ].forEach(([input, expected]) => { + it(`should process ${input} to ${expected}`, () => { + expect(getOperationId({} as OperationObject, input, '')).toBe(expected); + }); + }); +}); diff --git a/packages/core/src/getters/operation.ts b/packages/core/src/getters/operation.ts new file mode 100644 index 000000000..9b0450c88 --- /dev/null +++ b/packages/core/src/getters/operation.ts @@ -0,0 +1,27 @@ +import { OperationObject } from 'openapi3-ts'; +import { Verbs } from '../types'; +import { pascal, sanitize } from '../utils'; + +export const getOperationId = ( + operation: OperationObject, + route: string, + verb: Verbs, +): string => { + if (operation.operationId) { + return operation.operationId; + } + + return pascal( + [ + verb, + ...route.split('/').map((p) => + sanitize(p, { + dash: true, + underscore: '-', + dot: '-', + whitespace: '-', + }), + ), + ].join('-'), + ); +}; diff --git a/packages/core/src/getters/parameters.ts b/packages/core/src/getters/parameters.ts new file mode 100644 index 000000000..dd0a6cbb2 --- /dev/null +++ b/packages/core/src/getters/parameters.ts @@ -0,0 +1,42 @@ +import { ParameterObject, ReferenceObject } from 'openapi3-ts'; +import { resolveRef } from '../resolvers/ref'; +import { ContextSpecs, GetterParameters } from '../types'; +import { isReference } from '../utils'; + +export const getParameters = ({ + parameters = [], + context, +}: { + parameters: (ReferenceObject | ParameterObject)[]; + context: ContextSpecs; +}): GetterParameters => { + return parameters.reduce( + (acc, p) => { + if (isReference(p)) { + const { schema: parameter, imports } = resolveRef( + p, + context, + ); + + if ( + parameter.in === 'path' || + parameter.in === 'query' || + parameter.in === 'header' + ) { + acc[parameter.in].push({ parameter, imports }); + } + } else { + if (p.in === 'query' || p.in === 'path' || p.in === 'header') { + acc[p.in].push({ parameter: p, imports: [] }); + } + } + + return acc; + }, + { + path: [], + query: [], + header: [], + } as GetterParameters, + ); +}; diff --git a/packages/core/src/getters/params.ts b/packages/core/src/getters/params.ts new file mode 100644 index 000000000..ebafe1887 --- /dev/null +++ b/packages/core/src/getters/params.ts @@ -0,0 +1,107 @@ +import { resolveValue } from '../resolvers'; +import { ContextSpecs, GetterParameters, GetterParams } from '../types'; +import { camel, sanitize, stringify } from '../utils'; + +/** + * Return every params in a path + * + * @example + * ``` + * getParamsInPath("/pet/{category}/{name}/"); + * // => ["category", "name"] + * ``` + * @param path + */ +export const getParamsInPath = (path: string) => { + let n; + const output = []; + const templatePathRegex = /\{(.*?)\}/g; + // tslint:disable-next-line:no-conditional-assignment + while ((n = templatePathRegex.exec(path)) !== null) { + output.push(n[1]); + } + + return output; +}; + +export const getParams = ({ + route, + pathParams = [], + operationId, + context, +}: { + route: string; + pathParams?: GetterParameters['query']; + operationId: string; + context: ContextSpecs; +}): GetterParams => { + const params = getParamsInPath(route); + return params.map((p) => { + const pathParam = pathParams.find( + ({ parameter }) => + sanitize(camel(parameter.name), { + es5keyword: true, + underscore: true, + dash: true, + }) === p, + ); + + if (!pathParam) { + throw new Error( + `The path params ${p} can't be found in parameters (${operationId})`, + ); + } + + const { + name: nameWithoutSanitize, + required = false, + schema, + } = pathParam.parameter; + + const name = sanitize(camel(nameWithoutSanitize), { es5keyword: true }); + + if (!schema) { + return { + name, + definition: `${name}${!required ? '?' : ''}: unknown`, + implementation: `${name}${!required ? '?' : ''}: unknown`, + default: false, + required, + imports: [], + }; + } + + const resolvedValue = resolveValue({ + schema, + context: { + ...context, + ...(pathParam.imports.length + ? { + specKey: pathParam.imports[0].specKey, + } + : {}), + }, + }); + + const definition = `${name}${ + !required || resolvedValue.originalSchema!.default ? '?' : '' + }: ${resolvedValue.value}`; + + const implementation = `${name}${ + !required && !resolvedValue.originalSchema!.default ? '?' : '' + }${ + !resolvedValue.originalSchema!.default + ? `: ${resolvedValue.value}` + : `= ${stringify(resolvedValue.originalSchema!.default)}` + }`; + + return { + name, + definition, + implementation, + default: resolvedValue.originalSchema!.default, + required, + imports: resolvedValue.imports, + }; + }); +}; diff --git a/packages/core/src/getters/props.ts b/packages/core/src/getters/props.ts new file mode 100644 index 000000000..e89ad3ef8 --- /dev/null +++ b/packages/core/src/getters/props.ts @@ -0,0 +1,68 @@ +import { + GetterBody, + GetterParams, + GetterProps, + GetterPropType, + GetterQueryParam, +} from '../types'; +import { isUndefined, sortByPriority } from '../utils'; + +export const getProps = ({ + body, + queryParams, + params, + headers, +}: { + body: GetterBody; + queryParams?: GetterQueryParam; + params: GetterParams; + headers?: GetterQueryParam; +}): GetterProps => { + const bodyProp = { + name: body.implementation, + definition: `${body.implementation}: ${body.definition}`, + implementation: `${body.implementation}: ${body.definition}`, + default: false, + required: true, + type: GetterPropType.BODY, + }; + + const queryParamsProp = { + name: 'params', + definition: `params${queryParams?.isOptional ? '?' : ''}: ${ + queryParams?.schema.name + }`, + implementation: `params${queryParams?.isOptional ? '?' : ''}: ${ + queryParams?.schema.name + }`, + default: false, + required: !isUndefined(queryParams?.isOptional) + ? !queryParams?.isOptional + : false, + type: GetterPropType.QUERY_PARAM, + }; + + const headersProp = { + name: 'headers', + definition: `headers${headers?.isOptional ? '?' : ''}: ${ + headers?.schema.name + }`, + implementation: `headers${headers?.isOptional ? '?' : ''}: ${ + headers?.schema.name + }`, + default: false, + required: !isUndefined(headers?.isOptional) ? !headers?.isOptional : false, + type: GetterPropType.HEADER, + }; + + const props = [ + ...params.map((param) => ({ ...param, type: GetterPropType.PARAM })), + ...(body.definition ? [bodyProp] : []), + ...(queryParams ? [queryParamsProp] : []), + ...(headers ? [headersProp] : []), + ]; + + const sortedProps = sortByPriority(props); + + return sortedProps; +}; diff --git a/packages/core/src/getters/query-params.ts b/packages/core/src/getters/query-params.ts new file mode 100644 index 000000000..f2e4df3aa --- /dev/null +++ b/packages/core/src/getters/query-params.ts @@ -0,0 +1,117 @@ +import { ContentObject, SchemaObject } from 'openapi3-ts'; +import { resolveValue } from '../resolvers'; +import { + ContextSpecs, + GeneratorImport, + GeneratorSchema, + GetterParameters, + GetterQueryParam, +} from '../types'; +import { pascal, sanitize } from '../utils'; +import { getEnum } from './enum'; +import { getKey } from './keys'; + +type QueryParamsType = { + definition: string; + imports: GeneratorImport[]; + schemas: GeneratorSchema[]; +}; + +const getQueryParamsTypes = ( + queryParams: GetterParameters['query'], + operationName: string, + context: ContextSpecs, +): QueryParamsType[] => { + return queryParams.map(({ parameter, imports: parameterImports }) => { + const { name, required, schema, content } = parameter as { + name: string; + required: boolean; + schema: SchemaObject; + content: ContentObject; + }; + + const queryName = sanitize(`${pascal(operationName)}${pascal(name)}`, { + underscore: '_', + whitespace: '_', + dash: true, + es5keyword: true, + es5IdentifierName: true, + }); + + const { value, imports, isEnum, type, schemas, isRef } = resolveValue({ + schema: (schema || content['application/json'].schema)!, + context, + name: queryName, + }); + + const key = getKey(name); + + if (parameterImports.length) { + return { + definition: `${key}${!required || schema.default ? '?' : ''}: ${ + parameterImports[0].name + }`, + imports: parameterImports, + schemas: [], + }; + } + + if (isEnum && !isRef) { + const enumName = queryName; + const enumValue = getEnum(value, enumName); + + return { + definition: `${key}${ + !required || schema.default ? '?' : '' + }: ${enumName}`, + imports: [{ name: enumName }], + schemas: [...schemas, { name: enumName, model: enumValue, imports }], + }; + } + + const definition = `${key}${ + !required || schema.default ? '?' : '' + }: ${value}`; + + return { + definition, + imports, + schemas, + }; + }); +}; + +export const getQueryParams = ({ + queryParams = [], + operationName, + context, + suffix = 'params', +}: { + queryParams: GetterParameters['query']; + operationName: string; + context: ContextSpecs; + suffix?: string; +}): GetterQueryParam | undefined => { + if (!queryParams.length) { + return; + } + const types = getQueryParamsTypes(queryParams, operationName, context); + const imports = types.flatMap(({ imports }) => imports); + const schemas = types.flatMap(({ schemas }) => schemas); + const name = `${pascal(operationName)}${pascal(suffix)}`; + + const type = types.map(({ definition }) => definition).join('; '); + const allOptional = queryParams.every(({ parameter }) => !parameter.required); + + const schema = { + name, + model: `export type ${name} = { ${type} };\n`, + imports, + }; + + return { + schema, + deps: schemas, + isOptional: allOptional, + }; +}; diff --git a/packages/core/src/getters/ref.ts b/packages/core/src/getters/ref.ts new file mode 100644 index 000000000..8c7cbaae5 --- /dev/null +++ b/packages/core/src/getters/ref.ts @@ -0,0 +1,75 @@ +import get from 'lodash.get'; +import { ReferenceObject } from 'openapi3-ts'; +import { resolve } from 'upath'; +import url from 'url'; +import { ContextSpecs } from '../types'; +import { getExtension, getFileInfo, isUrl, pascal } from '../utils'; + +type RefComponent = 'schemas' | 'responses' | 'parameters' | 'requestBodies'; + +const RefComponent = { + schemas: 'schemas' as RefComponent, + responses: 'responses' as RefComponent, + parameters: 'parameters' as RefComponent, + requestBodies: 'requestBodies' as RefComponent, +}; + +export const RefComponentSuffix: Record = { + schemas: '', + responses: 'Response', + parameters: 'Parameter', + requestBodies: 'Body', +}; + +const regex = new RegExp('~1', 'g'); + +/** + * Return the output type from the $ref + * + * @param $ref + */ +export const getRefInfo = ( + $ref: ReferenceObject['$ref'], + context: ContextSpecs, +): { + name: string; + originalName: string; + refPaths?: string[]; + specKey?: string; +} => { + const [pathname, ref] = $ref.split('#'); + + const refPaths = ref + ?.slice(1) + .split('/') + .map((part) => part.replace(regex, '/')); + + const suffix = refPaths + ? get(context.override, [...refPaths.slice(0, 2), 'suffix'], '') + : ''; + + const originalName = ref + ? refPaths[refPaths.length - 1] + : pathname + .replace(`.${getExtension(pathname)}`, '') + .slice(pathname.lastIndexOf('/') + 1); + + if (!pathname) { + return { + name: pascal(originalName) + suffix, + originalName, + refPaths, + }; + } + + const path = isUrl(context.specKey) + ? url.resolve(context.specKey, pathname) + : resolve(getFileInfo(context.specKey).dirname, pathname); + + return { + name: pascal(originalName) + suffix, + originalName, + specKey: path, + refPaths, + }; +}; diff --git a/packages/core/src/getters/res-req-types.ts b/packages/core/src/getters/res-req-types.ts new file mode 100644 index 000000000..ec840ddcf --- /dev/null +++ b/packages/core/src/getters/res-req-types.ts @@ -0,0 +1,288 @@ +import { keyword } from 'esutils'; +import uniqBy from 'lodash.uniqby'; +import { + MediaTypeObject, + ReferenceObject, + RequestBodyObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; +import { resolveObject } from '../resolvers/object'; +import { resolveRef } from '../resolvers/ref'; +import { ContextSpecs, ResReqTypesValue } from '../types'; +import { camel } from '../utils'; +import { isReference } from '../utils/assertion'; +import { pascal } from '../utils/case'; +import { getNumberWord } from '../utils/string'; + +const formDataContentTypes = ['multipart/form-data']; + +const formUrlEncodedContentTypes = ['application/x-www-form-urlencoded']; + +const getResReqContentTypes = ({ + mediaType, + propName, + context, +}: { + mediaType: MediaTypeObject; + propName?: string; + context: ContextSpecs; +}) => { + if (!mediaType.schema) { + return undefined; + } + + const resolvedObject = resolveObject({ + schema: mediaType.schema, + propName, + context, + }); + + return resolvedObject; +}; + +export const getResReqTypes = ( + responsesOrRequests: Array< + [string, ResponseObject | ReferenceObject | RequestBodyObject] + >, + name: string, + context: ContextSpecs, + defaultType = 'unknown', +): ResReqTypesValue[] => { + const typesArray = responsesOrRequests + .filter(([_, res]) => Boolean(res)) + .map(([key, res]) => { + if (isReference(res)) { + const { + schema: bodySchema, + imports: [{ name, specKey, schemaName }], + } = resolveRef(res, context); + + const [contentType, mediaType] = + Object.entries(bodySchema.content ?? {})[0] ?? []; + + const isFormData = formDataContentTypes.includes(contentType); + const isFormUrlEncoded = + formUrlEncodedContentTypes.includes(contentType); + + if ((!isFormData && !isFormUrlEncoded) || !mediaType?.schema) { + return [ + { + value: name, + imports: [{ name, specKey, schemaName }], + schemas: [], + type: 'unknown', + isEnum: false, + isRef: true, + originalSchema: mediaType?.schema, + key, + contentType, + }, + ] as ResReqTypesValue[]; + } + + const formData = isFormData + ? getSchemaFormDataAndUrlEncoded({ + name, + schemaObject: mediaType?.schema, + context: { + ...context, + specKey: specKey || context.specKey, + }, + isRef: true, + }) + : undefined; + + const formUrlEncoded = isFormUrlEncoded + ? getSchemaFormDataAndUrlEncoded({ + name, + schemaObject: mediaType?.schema, + context: { + ...context, + specKey: specKey || context.specKey, + }, + isUrlEncoded: true, + isRef: true, + }) + : undefined; + + return [ + { + value: name, + imports: [{ name, specKey, schemaName }], + schemas: [], + type: 'unknown', + isEnum: false, + formData, + formUrlEncoded, + isRef: true, + originalSchema: mediaType?.schema, + key, + contentType, + }, + ] as ResReqTypesValue[]; + } + + if (res.content) { + const contents = Object.entries(res.content).map( + ([contentType, mediaType], index, arr) => { + let propName = key ? pascal(name) + pascal(key) : undefined; + + if (propName && arr.length > 1) { + propName = propName + pascal(getNumberWord(index + 1)); + } + + const resolvedValue = getResReqContentTypes({ + mediaType, + propName, + context, + }); + + if (!resolvedValue) { + return; + } + + const isFormData = formDataContentTypes.includes(contentType); + const isFormUrlEncoded = + formUrlEncodedContentTypes.includes(contentType); + + if ((!isFormData && !isFormUrlEncoded) || !propName) { + return { ...resolvedValue, contentType }; + } + + const formData = isFormData + ? getSchemaFormDataAndUrlEncoded({ + name: propName, + schemaObject: mediaType.schema!, + context, + }) + : undefined; + + const formUrlEncoded = isFormUrlEncoded + ? getSchemaFormDataAndUrlEncoded({ + name: propName, + schemaObject: mediaType.schema!, + context, + isUrlEncoded: true, + }) + : undefined; + + return { + ...resolvedValue, + formData, + formUrlEncoded, + contentType, + }; + }, + ); + + return contents + .filter((x) => x) + .map((x) => ({ ...x, key })) as ResReqTypesValue[]; + } + + return [ + { + value: defaultType, + imports: [], + schemas: [], + type: defaultType, + isEnum: false, + key, + isRef: false, + contentType: 'application/json', + }, + ] as ResReqTypesValue[]; + }); + + return uniqBy( + typesArray.flatMap((it) => it), + 'value', + ); +}; + +const getSchemaFormDataAndUrlEncoded = ({ + name, + schemaObject, + context, + isUrlEncoded, + isRef, +}: { + name: string; + schemaObject: SchemaObject | ReferenceObject; + context: ContextSpecs; + isUrlEncoded?: boolean; + isRef?: boolean; +}) => { + const { schema, imports } = resolveRef(schemaObject, context); + const propName = !isRef && isReference(schemaObject) ? imports[0].name : name; + + const variableName = isUrlEncoded ? 'formUrlEncoded' : 'formData'; + const form = isUrlEncoded + ? `const ${variableName} = new URLSearchParams();\n` + : `const ${variableName} = new FormData();\n`; + + if (schema.type === 'object' && schema.properties) { + const formDataValues = Object.entries(schema.properties).reduce( + (acc, [key, value]) => { + const { schema: property } = resolveRef(value, context); + + let formDataValue = ''; + + const formatedKey = !keyword.isIdentifierNameES5(key) + ? `['${key}']` + : `.${key}`; + + if (property.type === 'object') { + formDataValue = `${variableName}.append('${key}', JSON.stringify(${camel( + propName, + )}${formatedKey}));\n`; + } else if (property.type === 'array') { + formDataValue = `${camel( + propName, + )}${formatedKey}.forEach(value => ${variableName}.append('${key}', value));\n`; + } else if ( + property.type === 'number' || + property.type === 'integer' || + property.type === 'boolean' + ) { + formDataValue = `${variableName}.append('${key}', ${camel( + propName, + )}${formatedKey}.toString())\n`; + } else { + formDataValue = `${variableName}.append('${key}', ${camel( + propName, + )}${formatedKey})\n`; + } + + if (schema.required?.includes(key)) { + return acc + formDataValue; + } + + return ( + acc + + `if(${camel( + propName, + )}${formatedKey} !== undefined) {\n ${formDataValue} }\n` + ); + }, + '', + ); + + return `${form}${formDataValues}`; + } + + if (schema.type === 'array') { + return `${form}${camel( + propName, + )}.forEach(value => ${variableName}.append('data', value))\n`; + } + + if (schema.type === 'number' || schema.type === 'boolean') { + return `${form}${variableName}.append('data', ${camel( + propName, + )}.toString())\n`; + } + + return `${form}${variableName}.append('data', ${camel(propName)})\n`; +}; diff --git a/packages/core/src/getters/response.ts b/packages/core/src/getters/response.ts new file mode 100644 index 000000000..aeb79f73f --- /dev/null +++ b/packages/core/src/getters/response.ts @@ -0,0 +1,97 @@ +import { ResponsesObject } from 'openapi3-ts'; +import { + ContextSpecs, + GetterResponse, + OverrideOutputContentType, + ResReqTypesValue, +} from '../types'; +import { getResReqTypes } from './res-req-types'; + +export const getResponse = ({ + responses, + operationName, + context, + contentType, +}: { + responses: ResponsesObject; + operationName: string; + context: ContextSpecs; + contentType?: OverrideOutputContentType; +}): GetterResponse => { + if (!responses) { + return { + imports: [], + definition: { + success: '', + errors: '', + }, + isBlob: false, + types: { success: [], errors: [] }, + schemas: [], + contentTypes: [], + }; + } + + const types = getResReqTypes( + Object.entries(responses), + operationName, + context, + 'void', + ); + + const filteredTypes = contentType + ? types.filter((type) => { + let include = true; + let exclude = false; + + if (contentType.include) { + include = contentType.include.includes(type.contentType); + } + + if (contentType.exclude) { + exclude = contentType.exclude.includes(type.contentType); + } + + return include && !exclude; + }) + : types; + + const groupedByStatus = filteredTypes.reduce<{ + success: ResReqTypesValue[]; + errors: ResReqTypesValue[]; + }>( + (acc, type) => { + if (type.key.startsWith('2')) { + acc.success.push(type); + } else { + acc.errors.push(type); + } + return acc; + }, + { success: [], errors: [] }, + ); + + const imports = filteredTypes.flatMap(({ imports }) => imports); + const schemas = filteredTypes.flatMap(({ schemas }) => schemas); + + const contentTypes = [ + ...new Set(filteredTypes.map(({ contentType }) => contentType)), + ]; + + const success = groupedByStatus.success + .map(({ value, formData }) => (formData ? 'Blob' : value)) + .join(' | '); + const errors = groupedByStatus.errors.map(({ value }) => value).join(' | '); + + return { + imports, + definition: { + success: success || 'unknown', + errors: errors || 'unknown', + }, + isBlob: success === 'Blob', + types: groupedByStatus, + contentTypes, + schemas, + }; +}; diff --git a/packages/core/src/getters/route.test.ts b/packages/core/src/getters/route.test.ts new file mode 100644 index 000000000..152419c22 --- /dev/null +++ b/packages/core/src/getters/route.test.ts @@ -0,0 +1,20 @@ +import { getRoute } from './route'; + +describe('getRoute getter', () => { + [ + ['/api/test/{id}', '/api/test/${id}'], + ['/api/test/{path*}', '/api/test/${path}'], + ['/api/test/{user_id}', '/api/test/${userId}'], + ['/api/test/{locale}.js', '/api/test/${locale}.js'], + ['/api/test/i18n-{locale}.js', '/api/test/i18n-${locale}.js'], + ['/api/test/{param1}-{param2}.js', '/api/test/${param1}-${param2}.js'], + [ + '/api/test/user{param1}-{param2}.html', + '/api/test/user${param1}-${param2}.html', + ], + ].forEach(([input, expected]) => { + it(`should process ${input} to ${expected}`, () => { + expect(getRoute(input)).toBe(expected); + }); + }); +}); diff --git a/packages/core/src/getters/route.ts b/packages/core/src/getters/route.ts new file mode 100644 index 000000000..94c37b504 --- /dev/null +++ b/packages/core/src/getters/route.ts @@ -0,0 +1,39 @@ +import { camel, sanitize } from '../utils'; + +const hasParam = (path: string): boolean => /[^{]*{[\w*_-]*}.*/.test(path); + +const getRoutePath = (path: string): string => { + const matches = path.match(/([^{]*){?([\w*_-]*)}?(.*)/); + if (!matches?.length) return path; // impossible due to regexp grouping here, but for TS + + const prev = matches[1]; + const param = sanitize(camel(matches[2]), { + es5keyword: true, + underscore: true, + dash: true, + dot: true, + }); + const next = hasParam(matches[3]) ? getRoutePath(matches[3]) : matches[3]; + + if (hasParam(path)) { + return `${prev}\${${param}}${next}`; + } else { + return `${prev}${param}${next}`; + } +}; + +export const getRoute = (route: string) => { + const splittedRoute = route.split('/'); + + return splittedRoute.reduce((acc, path, i) => { + if (!path && !i) { + return acc; + } + + if (!path.includes('{')) { + return `${acc}/${path}`; + } + + return `${acc}/${getRoutePath(path)}`; + }, ''); +}; diff --git a/packages/core/src/getters/scalar.ts b/packages/core/src/getters/scalar.ts new file mode 100644 index 000000000..9b4519944 --- /dev/null +++ b/packages/core/src/getters/scalar.ts @@ -0,0 +1,128 @@ +import { SchemaObject } from 'openapi3-ts'; +import { ContextSpecs } from '../types'; +import { ResolverValue } from '../types'; +import { isString } from '../utils'; +import { escape } from '../utils'; +import { getArray } from './array'; +import { getObject } from './object'; + +/** + * Return the typescript equivalent of open-api data type + * + * @param item + * @ref https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#data-types + */ +export const getScalar = ({ + item, + name, + context, +}: { + item: SchemaObject; + name?: string; + context: ContextSpecs; +}): ResolverValue => { + const nullable = item.nullable ? ' | null' : ''; + + if (!item.type && item.items) { + item.type = 'array'; + } + + switch (item.type) { + case 'number': + case 'integer': { + let value = 'number'; + let isEnum = false; + + if (item.enum) { + value = item.enum.join(' | '); + isEnum = true; + } + + return { + value: value + nullable, + isEnum, + type: 'number', + schemas: [], + imports: [], + isRef: false, + }; + } + + case 'boolean': + return { + value: 'boolean' + nullable, + type: 'boolean', + isEnum: false, + schemas: [], + imports: [], + isRef: false, + }; + + case 'array': { + const { value, ...rest } = getArray({ + schema: item, + name, + context, + }); + return { + value: value + nullable, + ...rest, + }; + } + + case 'string': { + let value = 'string'; + let isEnum = false; + + if (item.enum) { + value = `'${item.enum + .map((enumItem: string) => + isString(enumItem) ? escape(enumItem) : enumItem, + ) + .filter(Boolean) + .join(`' | '`)}'`; + isEnum = true; + } + + if (item.format === 'binary') { + value = 'Blob'; + } + + if (context.override.useDates) { + if (item.format === 'date' || item.format === 'date-time') { + value = 'Date'; + } + } + + return { + value: value + nullable, + isEnum, + type: 'string', + imports: [], + schemas: [], + isRef: false, + }; + } + + case 'null': + return { + value: 'null', + isEnum: false, + type: 'null', + imports: [], + schemas: [], + isRef: false, + }; + + case 'object': + default: { + const { value, ...rest } = getObject({ + item, + name, + context, + nullable, + }); + return { value: value, ...rest }; + } + } +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 000000000..ca1730bf9 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,7 @@ +export * from './constants'; +export * from './generators'; +export * from './getters'; +export * from './resolvers'; +export * from './types'; +export * from './utils'; +export * from './writers'; diff --git a/packages/core/src/resolvers/index.ts b/packages/core/src/resolvers/index.ts new file mode 100644 index 000000000..ef9b94b63 --- /dev/null +++ b/packages/core/src/resolvers/index.ts @@ -0,0 +1,3 @@ +export * from './object'; +export * from './ref'; +export * from './value'; diff --git a/packages/core/src/resolvers/object.ts b/packages/core/src/resolvers/object.ts new file mode 100644 index 000000000..69894e1ab --- /dev/null +++ b/packages/core/src/resolvers/object.ts @@ -0,0 +1,71 @@ +import { ReferenceObject, SchemaObject } from 'openapi3-ts'; +import { getEnum } from '../getters/enum'; +import { ContextSpecs, ResolverValue } from '../types'; +import { jsDoc } from '../utils'; +import { resolveValue } from './value'; + +export const resolveObject = ({ + schema, + propName, + combined = false, + context, +}: { + schema: SchemaObject | ReferenceObject; + propName?: string; + combined?: boolean; + context: ContextSpecs; +}): ResolverValue => { + const resolvedValue = resolveValue({ + schema, + name: propName, + context, + }); + const doc = jsDoc(resolvedValue.originalSchema ?? {}); + + if ( + propName && + !resolvedValue.isEnum && + resolvedValue?.type === 'object' && + new RegExp(/{|&|\|/).test(resolvedValue.value) + ) { + return { + value: propName, + imports: [{ name: propName }], + schemas: [ + ...resolvedValue.schemas, + { + name: propName, + model: `${doc}export type ${propName} = ${resolvedValue.value};\n`, + imports: resolvedValue.imports, + }, + ], + isEnum: false, + type: 'object', + originalSchema: resolvedValue.originalSchema, + isRef: resolvedValue.isRef, + }; + } + + if (propName && resolvedValue.isEnum && !combined && !resolvedValue.isRef) { + const enumValue = getEnum(resolvedValue.value, propName); + + return { + value: propName, + imports: [{ name: propName }], + schemas: [ + ...resolvedValue.schemas, + { + name: propName, + model: doc + enumValue, + imports: resolvedValue.imports, + }, + ], + isEnum: false, + type: 'enum', + originalSchema: resolvedValue.originalSchema, + isRef: resolvedValue.isRef, + }; + } + + return resolvedValue; +}; diff --git a/packages/core/src/resolvers/ref.ts b/packages/core/src/resolvers/ref.ts new file mode 100644 index 000000000..58b5f7653 --- /dev/null +++ b/packages/core/src/resolvers/ref.ts @@ -0,0 +1,67 @@ +import get from 'lodash.get'; +import { + ParameterObject, + ReferenceObject, + RequestBodyObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; +import { getRefInfo } from '../getters/ref'; +import { ContextSpecs, GeneratorImport } from '../types'; +import { isReference } from '../utils'; + +type ComponentObject = + | SchemaObject + | ResponseObject + | ParameterObject + | RequestBodyObject + | ReferenceObject; +export const resolveRef = ( + schema: ComponentObject, + context: ContextSpecs, + imports: GeneratorImport[] = [], +): { + schema: Schema; + imports: GeneratorImport[]; +} => { + // the schema is refering to another object + if ((schema as any)?.schema?.$ref) { + const resolvedRef = resolveRef( + (schema as any)?.schema, + context, + imports, + ); + return { + schema: { + ...schema, + schema: resolvedRef.schema, + } as Schema, + imports, + }; + } + + if (!isReference(schema)) { + return { schema: schema as Schema, imports }; + } + + const { name, originalName, specKey, refPaths } = getRefInfo( + schema.$ref, + context, + ); + + const currentSchema = ( + refPaths + ? get(context.specs[specKey || context.specKey], refPaths) + : context.specs[specKey || context.specKey] + ) as Schema; + + if (!currentSchema) { + throw `Oups... 🍻. Ref not found: ${schema.$ref}`; + } + + return resolveRef( + currentSchema, + { ...context, specKey: specKey || context.specKey }, + [...imports, { name, specKey, schemaName: originalName }], + ); +}; diff --git a/packages/core/src/resolvers/value.ts b/packages/core/src/resolvers/value.ts new file mode 100644 index 000000000..731b040e9 --- /dev/null +++ b/packages/core/src/resolvers/value.ts @@ -0,0 +1,45 @@ +import { ReferenceObject, SchemaObject } from 'openapi3-ts'; +import { getScalar } from '../getters'; +import { ContextSpecs, ResolverValue, SchemaType } from '../types'; +import { isReference } from '../utils'; +import { resolveRef } from './ref'; + +export const resolveValue = ({ + schema, + name, + context, +}: { + schema: SchemaObject | ReferenceObject; + name?: string; + context: ContextSpecs; +}): ResolverValue => { + if (isReference(schema)) { + const { schema: schemaObject, imports } = resolveRef( + schema, + context, + ); + const { name, specKey, schemaName } = imports[0]; + + const importSpecKey = + specKey || + (context.specKey !== context.target ? context.specKey : undefined); + + return { + value: name, + imports: [{ name, specKey: importSpecKey, schemaName }], + type: (schemaObject?.type as SchemaType) || 'object', + schemas: [], + isEnum: !!schemaObject?.enum, + originalSchema: schemaObject, + isRef: true, + }; + } + + const scalar = getScalar({ item: schema, name, context }); + + return { + ...scalar, + originalSchema: schema, + isRef: false, + }; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 000000000..b97b08300 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,746 @@ +import SwaggerParser from '@apidevtools/swagger-parser'; +import { + InfoObject, + OpenAPIObject, + OperationObject, + ParameterObject, + SchemaObject, +} from 'openapi3-ts'; +import swagger2openapi from 'swagger2openapi'; + +export interface Options { + output?: string | OutputOptions; + input?: string | InputOptions; + hooks?: Partial; +} + +export type OptionsFn = () => Options | Promise; +export type OptionsExport = Options | Promise | OptionsFn; + +export type Config = { + [project: string]: OptionsExport; +}; +export type ConfigFn = () => Config | Promise; + +export type ConfigExternal = Config | Promise | ConfigFn; + +export type NormizaledConfig = { + [project: string]: NormalizedOptions; +}; + +export interface NormalizedOptions { + output: NormalizedOutputOptions; + input: NormalizedInputOptions; + hooks: NormalizedHookOptions; +} + +export type NormalizedOutputOptions = { + workspace?: string; + target?: string; + schemas?: string; + mode: OutputMode; + mock: boolean | ClientMSWBuilder; + override: NormalizedOverrideOutput; + client: OutputClient | OutputClientFunc; + clean: boolean | string[]; + prettier: boolean; + tslint: boolean; + tsconfig?: Tsconfig; + packageJson?: PackageJson; + headers: boolean; +}; + +export type NormalizedOverrideOutput = { + title?: (title: string) => string; + transformer?: OutputTransformer; + mutator?: NormalizedMutator; + operations: { [key: string]: NormalizedOperationOptions }; + tags: { [key: string]: NormalizedOperationOptions }; + mock?: { + arrayMin?: number; + arrayMax?: number; + properties?: MockProperties; + format?: { [key: string]: unknown }; + required?: boolean; + baseUrl?: string; + delay?: number; + }; + contentType?: OverrideOutputContentType; + header: false | ((info: InfoObject) => string[] | string); + formData: boolean | NormalizedMutator; + formUrlEncoded: boolean | NormalizedMutator; + components: { + schemas: { + suffix: string; + }; + responses: { + suffix: string; + }; + parameters: { + suffix: string; + }; + requestBodies: { + suffix: string; + }; + }; + query: QueryOptions; + angular: Required; + swr: { + options?: any; + }; + operationName?: ( + operation: OperationObject, + route: string, + verb: Verbs, + ) => string; + requestOptions: Record | boolean; + useDates?: boolean; + useTypeOverInterfaces?: boolean; + useDeprecatedOperations?: boolean; +}; + +export type NormalizedMutator = { + path: string; + name?: string; + default: boolean; + alias?: Record; +}; + +export type NormalizedOperationOptions = { + transformer?: OutputTransformer; + mutator?: NormalizedMutator; + mock?: { + data?: MockProperties; + properties?: MockProperties; + }; + contentType?: OverrideOutputContentType; + query?: QueryOptions; + angular?: Required; + swr?: { + options?: any; + }; + operationName?: ( + operation: OperationObject, + route: string, + verb: Verbs, + ) => string; + formData?: boolean | NormalizedMutator; + formUrlEncoded?: boolean | NormalizedMutator; + requestOptions?: object | boolean; +}; +export type NormalizedInputOptions = { + target: string | Record | OpenAPIObject; + validation: boolean; + override: OverrideInput; + converterOptions: swagger2openapi.Options; + parserOptions: SwaggerParserOptions; +}; + +export type OutputClientFunc = ( + clients: GeneratorClients, +) => ClientGeneratorsBuilder; + +export type OutputOptions = { + workspace?: string; + target?: string; + schemas?: string; + mode?: OutputMode; + mock?: boolean | ClientMSWBuilder; + override?: OverrideOutput; + client?: OutputClient | OutputClientFunc; + clean?: boolean | string[]; + prettier?: boolean; + tslint?: boolean; + tsconfig?: string | Tsconfig; + packageJson?: string; + headers?: boolean; +}; + +export type SwaggerParserOptions = Omit & { + validate?: boolean; +}; + +export type InputOptions = { + target: string | Record | OpenAPIObject; + validation?: boolean; + override?: OverrideInput; + converterOptions?: swagger2openapi.Options; + parserOptions?: SwaggerParserOptions; +}; + +export type OutputClient = + | 'axios' + | 'axios-functions' + | 'angular' + | 'react-query' + | 'svelte-query' + | 'vue-query' + | 'swr'; + +export const OutputClient = { + ANGULAR: 'angular' as OutputClient, + AXIOS: 'axios' as OutputClient, + AXIOS_FUNCTIONS: 'axios-functions' as OutputClient, + REACT_QUERY: 'react-query' as OutputClient, + SVELTE_QUERY: 'svelte-query' as OutputClient, + VUE_QUERY: 'vue-query' as OutputClient, +}; + +export type OutputMode = 'single' | 'split' | 'tags' | 'tags-split'; +export const OutputMode = { + SINGLE: 'single' as OutputMode, + SPLIT: 'split' as OutputMode, + TAGS: 'tags' as OutputMode, + TAGS_SPLIT: 'tags-split' as OutputMode, +}; + +export type MockOptions = { + arrayMin?: number; + arrayMax?: number; + required?: boolean; + properties?: Record; + operations?: Record }>; + format?: Record; + tags?: Record }>; +}; + +export type MockProperties = + | { [key: string]: unknown } + | ((specs: OpenAPIObject) => { [key: string]: unknown }); + +type OutputTransformerFn = (verb: GeneratorVerbOptions) => GeneratorVerbOptions; + +type OutputTransformer = string | OutputTransformerFn; + +export type MutatorObject = { + path: string; + name?: string; + default?: boolean; + alias?: Record; +}; + +export type Mutator = string | MutatorObject; + +export type OverrideOutput = { + title?: (title: string) => string; + transformer?: OutputTransformer; + mutator?: Mutator; + operations?: { [key: string]: OperationOptions }; + tags?: { [key: string]: OperationOptions }; + mock?: { + arrayMin?: number; + arrayMax?: number; + properties?: MockProperties; + format?: { [key: string]: unknown }; + required?: boolean; + baseUrl?: string; + delay?: number; + }; + contentType?: OverrideOutputContentType; + header?: boolean | ((info: InfoObject) => string[] | string); + formData?: boolean | Mutator; + formUrlEncoded?: boolean | Mutator; + components?: { + schemas?: { + suffix?: string; + }; + responses?: { + suffix?: string; + }; + parameters?: { + suffix?: string; + }; + requestBodies?: { + suffix?: string; + }; + }; + query?: QueryOptions; + swr?: { + options?: any; + }; + angular?: AngularOptions; + operationName?: ( + operation: OperationObject, + route: string, + verb: Verbs, + ) => string; + requestOptions?: Record | boolean; + useDates?: boolean; + useTypeOverInterfaces?: boolean; + useDeprecatedOperations?: boolean; +}; + +export type OverrideOutputContentType = { + include?: string[]; + exclude?: string[]; +}; + +type QueryOptions = { + useQuery?: boolean; + useInfinite?: boolean; + useInfiniteQueryParam?: string; + options?: any; + signal?: boolean; +}; + +export type AngularOptions = { + provideIn?: 'root' | 'any' | boolean; +}; + +export type InputTransformerFn = (spec: OpenAPIObject) => OpenAPIObject; + +type InputTransformer = string | InputTransformerFn; + +export type OverrideInput = { + transformer?: InputTransformer; +}; + +export type OperationOptions = { + transformer?: OutputTransformer; + mutator?: Mutator; + mock?: { + data?: MockProperties; + properties?: MockProperties; + }; + query?: QueryOptions; + angular?: Required; + swr?: { + options?: any; + }; + operationName?: ( + operation: OperationObject, + route: string, + verb: Verbs, + ) => string; + formData?: boolean | Mutator; + formUrlEncoded?: boolean | Mutator; + requestOptions?: object | boolean; +}; + +export type Hook = 'afterAllFilesWrite'; + +export type HookFunction = (...args: any[]) => void | Promise; + +export type HookCommand = string | HookFunction | (string | HookFunction)[]; + +export type NormalizedHookCommand = HookCommand[]; + +export type HooksOptions = Partial< + Record +>; + +export type NormalizedHookOptions = HooksOptions; + +export type Verbs = 'post' | 'put' | 'get' | 'patch' | 'delete' | 'head'; + +export const Verbs = { + POST: 'post' as Verbs, + PUT: 'put' as Verbs, + GET: 'get' as Verbs, + PATCH: 'patch' as Verbs, + DELETE: 'delete' as Verbs, + HEAD: 'head' as Verbs, +}; + +export type ImportOpenApi = { + data: Record; + input: InputOptions; + output: NormalizedOutputOptions; + target: string; + workspace: string; +}; + +export interface ContextSpecs { + specKey: string; + target: string; + workspace: string; + tslint: boolean; + specs: Record; + override: NormalizedOverrideOutput; + tsconfig?: Tsconfig; + packageJson?: PackageJson; +} + +export interface GlobalOptions { + projectName?: string; + watch?: boolean | string | (string | boolean)[]; + clean?: boolean | string[]; + prettier?: boolean; + tslint?: boolean; + mock?: boolean; + client?: OutputClient; + mode?: OutputMode; + tsconfig?: string | Tsconfig; + packageJson?: string; + input?: string; + output?: string; +} + +export interface Tsconfig { + baseUrl?: string; + compilerOptions?: { + esModuleInterop?: boolean; + allowSyntheticDefaultImports?: boolean; + exactOptionalPropertyTypes?: boolean; + paths?: Record; + }; +} + +export interface PackageJson { + dependencies?: Record; + devDependencies?: Record; +} + +export type GeneratorSchema = { + name: string; + model: string; + imports: GeneratorImport[]; +}; + +export type GeneratorImport = { + name: string; + schemaName?: string; + alias?: string; + specKey?: string; + default?: boolean; + values?: boolean; + syntheticDefaultImport?: boolean; +}; + +export type GeneratorDependency = { + exports: GeneratorImport[]; + dependency: string; +}; + +export type GeneratorApiResponse = { + operations: GeneratorOperations; + schemas: GeneratorSchema[]; +}; + +export type GeneratorOperations = { + [operationId: string]: GeneratorOperation; +}; + +export type GeneratorTarget = { + imports: GeneratorImport[]; + implementation: string; + implementationMSW: string; + importsMSW: GeneratorImport[]; + mutators?: GeneratorMutator[]; + formData?: GeneratorMutator[]; + formUrlEncoded?: GeneratorMutator[]; +}; + +export type GeneratorTargetFull = { + imports: GeneratorImport[]; + implementation: string; + implementationMSW: { + function: string; + handler: string; + }; + importsMSW: GeneratorImport[]; + mutators?: GeneratorMutator[]; + formData?: GeneratorMutator[]; + formUrlEncoded?: GeneratorMutator[]; +}; + +export type GeneratorOperation = { + imports: GeneratorImport[]; + implementation: string; + implementationMSW: { function: string; handler: string }; + importsMSW: GeneratorImport[]; + tags: string[]; + mutator?: GeneratorMutator; + formData?: GeneratorMutator; + formUrlEncoded?: GeneratorMutator; + operationName: string; + types?: { + result: (title?: string) => string; + }; +}; + +export type GeneratorVerbOptions = { + verb: Verbs; + summary?: string; + doc: string; + tags: string[]; + operationId: string; + operationName: string; + response: GetterResponse; + body: GetterBody; + headers?: GetterQueryParam; + queryParams?: GetterQueryParam; + params: GetterParams; + props: GetterProps; + mutator?: GeneratorMutator; + formData?: GeneratorMutator; + formUrlEncoded?: GeneratorMutator; + override: NormalizedOverrideOutput; + deprecated?: boolean; +}; + +export type GeneratorVerbsOptions = GeneratorVerbOptions[]; + +export type GeneratorOptions = { + route: string; + pathRoute: string; + override: NormalizedOverrideOutput; + context: ContextSpecs; + mock: boolean; +}; + +export type GeneratorClient = { + implementation: string; + imports: GeneratorImport[]; + types?: { + result: (title?: string) => string; + }; +}; + +export type GeneratorMutatorParsingInfo = { + numberOfParams: number; + returnNumberOfParams?: number; +}; +export type GeneratorMutator = { + name: string; + path: string; + default: boolean; + hasErrorType: boolean; + errorTypeName: string; + hasSecondArg: boolean; + hasThirdArg: boolean; + isHook: boolean; + bodyTypeName?: string; +}; + +export type ClientBuilder = ( + verbOptions: GeneratorVerbOptions, + options: GeneratorOptions, + outputClient: OutputClient | OutputClientFunc, +) => GeneratorClient; + +export type ClientHeaderBuilder = (params: { + title: string; + isRequestOptions: boolean; + isMutator: boolean; + noFunction?: boolean; + isGlobalMutator: boolean; + provideIn: boolean | 'root' | 'any'; + hasAwaitedType: boolean; +}) => string; + +export type ClientFooterBuilder = (params: { + noFunction?: boolean | undefined; + operationNames: string[]; + title?: string; + hasAwaitedType: boolean; + hasMutator: boolean; +}) => string; + +export type ClientTitleBuilder = (title: string) => string; + +export type ClientDependenciesBuilder = ( + hasGlobalMutator: boolean, + packageJson?: PackageJson, +) => GeneratorDependency[]; + +export type ClientMSWBuilder = ( + verbOptions: GeneratorVerbOptions, + generatorOptions: GeneratorOptions, +) => { + imports: string[]; + implementation: string; +}; + +export interface ClientGeneratorsBuilder { + client: ClientBuilder; + header: ClientHeaderBuilder; + dependencies: ClientDependenciesBuilder; + footer: ClientFooterBuilder; + title: ClientTitleBuilder; +} + +export type GeneratorClients = Record; + +export type GetterResponse = { + imports: GeneratorImport[]; + definition: { + success: string; + errors: string; + }; + isBlob: boolean; + types: { + success: ResReqTypesValue[]; + errors: ResReqTypesValue[]; + }; + contentTypes: string[]; + schemas: GeneratorSchema[]; +}; + +export type GetterBody = { + imports: GeneratorImport[]; + definition: string; + implementation: string; + schemas: GeneratorSchema[]; + formData?: string; + formUrlEncoded?: string; + contentType: string; +}; + +export type GetterParameters = { + query: { parameter: ParameterObject; imports: GeneratorImport[] }[]; + path: { parameter: ParameterObject; imports: GeneratorImport[] }[]; + header: { parameter: ParameterObject; imports: GeneratorImport[] }[]; +}; + +export type GetterParam = { + name: string; + definition: string; + implementation: string; + default: boolean; + required: boolean; + imports: GeneratorImport[]; +}; + +export type GetterParams = GetterParam[]; +export type GetterQueryParam = { + schema: GeneratorSchema; + deps: GeneratorSchema[]; + isOptional: boolean; +}; + +export type GetterPropType = 'param' | 'body' | 'queryParam' | 'header'; + +export const GetterPropType = { + PARAM: 'param' as GetterPropType, + BODY: 'body' as GetterPropType, + QUERY_PARAM: 'queryParam' as GetterPropType, + HEADER: 'header' as GetterPropType, +}; + +export type GetterProp = { + name: string; + definition: string; + implementation: string; + default: boolean; + required: boolean; + type: GetterPropType; +}; + +export type GetterProps = GetterProp[]; + +export type SchemaType = + | 'integer' + | 'number' + | 'string' + | 'boolean' + | 'object' + | 'null' + | 'array' + | 'enum' + | 'unknown'; + +export type ResolverValue = { + value: string; + isEnum: boolean; + type: SchemaType; + imports: GeneratorImport[]; + schemas: GeneratorSchema[]; + originalSchema?: SchemaObject; + isRef: boolean; +}; + +export type ResReqTypesValue = ResolverValue & { + formData?: string; + formUrlEncoded?: string; + isRef?: boolean; + key: string; + contentType: string; +}; + +export type WriteSpecsBuilder = { + operations: GeneratorOperations; + schemas: Record; + title: GeneratorClientTitle; + header: GeneratorClientHeader; + footer: GeneratorClientFooter; + imports: GeneratorClientImports; + importsMock: GenerateMockImports; + info: InfoObject; + target: string; +}; + +export type WriteModeProps = { + builder: WriteSpecsBuilder; + output: NormalizedOutputOptions; + workspace: string; + specsName: Record; + header: string; +}; + +export type GeneratorApiOperations = { + operations: GeneratorOperations; + schemas: GeneratorSchema[]; +}; + +export type GeneratorClientExtra = { + implementation: string; + implementationMSW: string; +}; + +export type GeneratorClientTitle = (data: { + outputClient?: OutputClient | OutputClientFunc; + title: string; + customTitleFunc?: (title: string) => string; +}) => GeneratorClientExtra; + +export type GeneratorClientHeader = (data: { + outputClient?: OutputClient | OutputClientFunc; + isRequestOptions: boolean; + isMutator: boolean; + isGlobalMutator: boolean; + provideIn: boolean | 'root' | 'any'; + hasAwaitedType: boolean; + titles: GeneratorClientExtra; +}) => GeneratorClientExtra; + +export type GeneratorClientFooter = (data: { + outputClient: OutputClient | OutputClientFunc; + operationNames: string[]; + hasMutator: boolean; + hasAwaitedType: boolean; + titles: GeneratorClientExtra; +}) => GeneratorClientExtra; + +export type GeneratorClientImports = (data: { + client: OutputClient | OutputClientFunc; + implementation: string; + imports: { + exports: GeneratorImport[]; + dependency: string; + }[]; + specsName: Record; + hasSchemaDir: boolean; + isAllowSyntheticDefaultImports: boolean; + hasGlobalMutator: boolean; + packageJson?: PackageJson; +}) => string; + +export type GenerateMockImports = (data: { + implementation: string; + imports: { + exports: GeneratorImport[]; + dependency: string; + }[]; + specsName: Record; + hasSchemaDir: boolean; + isAllowSyntheticDefaultImports: boolean; +}) => string; + +export type GeneratorApiBuilder = GeneratorApiOperations & { + title: GeneratorClientTitle; + header: GeneratorClientHeader; + footer: GeneratorClientFooter; + imports: GeneratorClientImports; + importsMock: GenerateMockImports; +}; diff --git a/packages/core/src/utils/assertion.ts b/packages/core/src/utils/assertion.ts new file mode 100644 index 000000000..c0e89feb5 --- /dev/null +++ b/packages/core/src/utils/assertion.ts @@ -0,0 +1,58 @@ +import { ReferenceObject } from 'openapi3-ts'; +import { extname } from 'upath'; +import validatorIsUrl from 'validator/lib/isURL'; +import { Verbs } from '../types'; + +/** + * Discriminator helper for `ReferenceObject` + * + * @param property + */ +export const isReference = (property: any): property is ReferenceObject => { + return Boolean(property.$ref); +}; + +export const isDirectory = (path: string) => { + return !extname(path); +}; + +export function isObject(x: any): x is Record { + return Object.prototype.toString.call(x) === '[object Object]'; +} + +export function isString(x: any): x is string { + return typeof x === 'string'; +} + +export function isNumber(x: any): x is number { + return typeof x === 'number'; +} + +export function isBoolean(x: any): x is boolean { + return typeof x === 'boolean'; +} + +export function isFunction(x: any): x is Function { + return typeof x === 'function'; +} + +export function isUndefined(x: any): x is undefined { + return typeof x === 'undefined'; +} + +export function isNull(x: any): x is null { + return typeof x === null; +} + +export const isVerb = (verb: string): verb is Verbs => + Object.values(Verbs).includes(verb as Verbs); + +export const isRootKey = (specKey: string, target: string) => { + return specKey === target; +}; + +const LOCALHOST_REGEX = /^https?:\/\/\w+(\.\w+)*(:[0-9]+)?(\/.*)?$/; + +export const isUrl = (str: string) => { + return validatorIsUrl(str) || LOCALHOST_REGEX.test(str); +}; diff --git a/packages/core/src/utils/async-reduce.ts b/packages/core/src/utils/async-reduce.ts new file mode 100644 index 000000000..92039fd26 --- /dev/null +++ b/packages/core/src/utils/async-reduce.ts @@ -0,0 +1,19 @@ +export async function asyncReduce( + array: IterationItem[], + reducer: ( + accumulate: AccValue, + current: IterationItem, + ) => AccValue | Promise, + initValue: AccValue, +): Promise { + let accumulate = + typeof initValue === 'object' + ? Object.create(initValue as unknown as object) + : initValue; + + for (const item of array) { + accumulate = await reducer(accumulate, item); + } + + return accumulate; +} diff --git a/packages/core/src/utils/case.ts b/packages/core/src/utils/case.ts new file mode 100644 index 000000000..893d33427 --- /dev/null +++ b/packages/core/src/utils/case.ts @@ -0,0 +1,127 @@ +const unicodes = function (s: string, prefix: string) { + prefix = prefix || ''; + return s.replace(/(^|-)/g, '$1\\u' + prefix).replace(/,/g, '\\u' + prefix); +}; + +const symbols = unicodes('20-26,28-2F,3A-40,5B-60,7B-7E,A0-BF,D7,F7', '00'); +const lowers = 'a-z' + unicodes('DF-F6,F8-FF', '00'); +const uppers = 'A-Z' + unicodes('C0-D6,D8-DE', '00'); +const impropers = + 'A|An|And|As|At|But|By|En|For|If|In|Of|On|Or|The|To|Vs?\\.?|Via'; + +const regexps = { + capitalize: new RegExp('(^|[' + symbols + '])([' + lowers + '])', 'g'), + pascal: new RegExp('(^|[' + symbols + '])+([' + lowers + uppers + '])', 'g'), + fill: new RegExp('[' + symbols + ']+(.|$)', 'g'), + sentence: new RegExp( + '(^\\s*|[\\?\\!\\.]+"?\\s+"?|,\\s+")([' + lowers + '])', + 'g', + ), + improper: new RegExp('\\b(' + impropers + ')\\b', 'g'), + relax: new RegExp( + '([^' + + uppers + + '])([' + + uppers + + ']*)([' + + uppers + + '])(?=[^' + + uppers + + ']|$)', + 'g', + ), + upper: new RegExp('^[^' + lowers + ']+$'), + hole: /[^\s]\s[^\s]/, + apostrophe: /'/g, + room: new RegExp('[' + symbols + ']'), +}; + +const deapostrophe = (s: string) => { + return s.replace(regexps.apostrophe, ''); +}; + +const up = String.prototype.toUpperCase; +const low = String.prototype.toLowerCase; + +const fill = (s: string, fillWith?: string, isDeapostrophe = false) => { + if (fillWith != null) { + s = s.replace(regexps.fill, function (m, next) { + return next ? fillWith + next : ''; + }); + } + if (isDeapostrophe) { + s = deapostrophe(s); + } + return s; +}; + +const decap = (s: string) => { + return low.call(s.charAt(0)) + s.slice(1); +}; + +const relax = ( + m: string, + before: string, + acronym: string | undefined, + caps: string, +) => { + return before + ' ' + (acronym ? acronym + ' ' : '') + caps; +}; + +const prep = (s: string, isFill = false, isPascal = false, isUpper = false) => { + s = s == null ? '' : s + ''; // force to string + if (!isUpper && regexps.upper.test(s)) { + s = low.call(s); + } + if (!isFill && !regexps.hole.test(s)) { + var holey = fill(s, ' '); + if (regexps.hole.test(holey)) { + s = holey; + } + } + if (!isPascal && !regexps.room.test(s)) { + s = s.replace(regexps.relax, relax); + } + return s; +}; + +const lower = (s: string, fillWith: string, isDeapostrophe: boolean) => { + return fill(low.call(prep(s, !!fillWith)), fillWith, isDeapostrophe); +}; + +export const pascal = (s: string) => { + return fill( + prep(s, false, true).replace( + regexps.pascal, + (m: string, border: string, letter: string) => { + return up.call(letter); + }, + ), + '', + true, + ); +}; + +export const camel = (s: string) => { + return decap(pascal(s)); +}; + +export const snake = (s: string) => { + return lower(s, '_', true); +}; + +export const kebab = (s: string) => { + return lower(s, '-', true); +}; + +export const upper = ( + s: string, + fillWith?: string, + isDeapostrophe?: boolean, +) => { + return fill( + up.call(prep(s, !!fillWith, false, true)), + fillWith, + isDeapostrophe, + ); +}; diff --git a/packages/core/src/utils/compare-version.ts b/packages/core/src/utils/compare-version.ts new file mode 100644 index 000000000..4db4558a9 --- /dev/null +++ b/packages/core/src/utils/compare-version.ts @@ -0,0 +1,17 @@ +import { compare, CompareOperator } from 'compare-versions'; + +export const compareVersions = ( + firstVersion: string, + secondVersions: string, + operator: CompareOperator = '>=', +) => { + if (firstVersion === 'latest') { + return true; + } + + return compare( + firstVersion.replace(/(\s(.*))/, ''), + secondVersions, + operator, + ); +}; diff --git a/packages/core/src/utils/debug.ts b/packages/core/src/utils/debug.ts new file mode 100644 index 000000000..c8828e618 --- /dev/null +++ b/packages/core/src/utils/debug.ts @@ -0,0 +1,26 @@ +import debug from 'debug'; + +const filter = process.env.ORVAL_DEBUG_FILTER; +const DEBUG = process.env.DEBUG; + +interface DebuggerOptions { + onlyWhenFocused?: boolean | string; +} + +export function createDebugger( + ns: string, + options: DebuggerOptions = {}, +): debug.Debugger['log'] { + const log = debug(ns); + const { onlyWhenFocused } = options; + const focus = typeof onlyWhenFocused === 'string' ? onlyWhenFocused : ns; + return (msg: string, ...args: any[]) => { + if (filter && !msg.includes(filter)) { + return; + } + if (onlyWhenFocused && !DEBUG?.includes(focus)) { + return; + } + log(msg, ...args); + }; +} diff --git a/packages/core/src/utils/doc.ts b/packages/core/src/utils/doc.ts new file mode 100644 index 000000000..ffd88def9 --- /dev/null +++ b/packages/core/src/utils/doc.ts @@ -0,0 +1,68 @@ +const search = '\\*/'; // Find '*/' +const replacement = '*\\/'; // Replace With '*\/' + +const regex = new RegExp(search, 'g'); + +export function jsDoc( + { + description, + deprecated, + summary, + }: { + description?: string[] | string; + deprecated?: boolean; + summary?: string; + }, + tryOneLine = false, +): string { + // Ensure there aren't any comment terminations in doc + const lines = ( + Array.isArray(description) + ? description.filter((d) => !d.includes('eslint-disable')) + : [description || ''] + ).map((line) => line.replace(regex, replacement)); + + const count = [description, deprecated, summary].reduce( + (acc, it) => (it ? acc + 1 : acc), + 0, + ); + + if (!count) { + return ''; + } + + const oneLine = count === 1 && tryOneLine; + const eslintDisable = Array.isArray(description) + ? description + .find((d) => d.includes('eslint-disable')) + ?.replace(regex, replacement) + : undefined; + let doc = `${eslintDisable ? `/* ${eslintDisable} */\n` : ''}/**`; + + if (description) { + if (!oneLine) { + doc += `\n${tryOneLine ? ' ' : ''} *`; + } + doc += ` ${lines.join('\n * ')}`; + } + + if (deprecated) { + if (!oneLine) { + doc += `\n${tryOneLine ? ' ' : ''} *`; + } + doc += ' @deprecated'; + } + + if (summary) { + if (!oneLine) { + doc += `\n${tryOneLine ? ' ' : ''} *`; + } + doc += ` @summary ${summary.replace(regex, replacement)}`; + } + + doc += !oneLine ? `\n ${tryOneLine ? ' ' : ''}` : ' '; + + doc += '*/\n'; + + return doc; +} diff --git a/packages/core/src/utils/dynamic-import.ts b/packages/core/src/utils/dynamic-import.ts new file mode 100644 index 000000000..118bb025c --- /dev/null +++ b/packages/core/src/utils/dynamic-import.ts @@ -0,0 +1,28 @@ +import { resolve } from 'upath'; +import { isObject, isString } from './assertion'; + +export const dynamicImport = async ( + toImport: T | string, + from = process.cwd(), + takeDefault = true, +): Promise => { + if (!toImport) { + return toImport as T; + } + + try { + if (isString(toImport)) { + const path = resolve(from, toImport); + const data = await import(path); + if (takeDefault && isObject(data) && data.default) { + return (data as any).default as T; + } + + return data; + } + + return Promise.resolve(toImport); + } catch (error) { + throw `Oups... 🍻. Path: ${toImport} => ${error}`; + } +}; diff --git a/packages/core/src/utils/extension.ts b/packages/core/src/utils/extension.ts new file mode 100644 index 000000000..00c06f881 --- /dev/null +++ b/packages/core/src/utils/extension.ts @@ -0,0 +1,4 @@ +export const getExtension = (path: string) => + path.toLowerCase().includes('.yaml') || path.toLowerCase().includes('.yml') + ? 'yaml' + : 'json'; diff --git a/packages/core/src/utils/file.ts b/packages/core/src/utils/file.ts new file mode 100644 index 000000000..59f27415a --- /dev/null +++ b/packages/core/src/utils/file.ts @@ -0,0 +1,374 @@ +import chalk from 'chalk'; +import { build, PluginBuild } from 'esbuild'; +import fs from 'fs'; +import glob from 'globby'; +import mm from 'micromatch'; +import path from 'path'; +import { + basename, + dirname, + extname, + join, + joinSafe, + normalizeSafe, + resolve, +} from 'upath'; +import { Tsconfig } from '../types'; +import { isDirectory } from './assertion'; +import { createDebugger } from './debug'; +import { createLogger, LogLevel } from './logger'; + +export const getFileInfo = ( + target: string = '', + { + backupFilename = 'filename', + extension = '.ts', + }: { backupFilename?: string; extension?: string } = {}, +) => { + const isDir = isDirectory(target); + const path = isDir ? join(target, backupFilename + extension) : target; + const pathWithoutExtension = path.replace(/\.[^/.]+$/, ''); + const dir = dirname(path); + const filename = basename( + path, + extension[0] !== '.' ? `.${extension}` : extension, + ); + + return { + path, + pathWithoutExtension, + extension, + isDirectory: isDir, + dirname: dir, + filename, + }; +}; + +const debug = createDebugger('orval:file-load'); + +const cache = new Map(); + +export async function loadFile( + filePath?: string, + options?: { + root?: string; + defaultFileName?: string; + logLevel?: LogLevel; + isDefault?: boolean; + alias?: Record; + tsconfig?: Tsconfig; + load?: boolean; + }, +): Promise<{ + path: string; + file?: File; + error?: any; + cached?: boolean; +}> { + const { + root = process.cwd(), + isDefault = true, + defaultFileName, + logLevel, + alias, + tsconfig, + load = true, + } = options ?? {}; + const start = Date.now(); + + let resolvedPath: string | undefined; + let isTS = false; + let isMjs = false; + + if (filePath) { + // explicit path is always resolved from cwd + resolvedPath = path.resolve(filePath); + isTS = filePath.endsWith('.ts'); + } else if (defaultFileName) { + // implicit file loaded from inline root (if present) + // otherwise from cwd + const jsFile = path.resolve(root, `${defaultFileName}.js`); + if (fs.existsSync(jsFile)) { + resolvedPath = jsFile; + } + + if (!resolvedPath) { + const mjsFile = path.resolve(root, `${defaultFileName}.mjs`); + if (fs.existsSync(mjsFile)) { + resolvedPath = mjsFile; + isMjs = true; + } + } + + if (!resolvedPath) { + const tsFile = path.resolve(root, `${defaultFileName}.ts`); + if (fs.existsSync(tsFile)) { + resolvedPath = tsFile; + isTS = true; + } + } + } + + if (!resolvedPath) { + if (filePath) { + createLogger(logLevel).error(chalk.red(`File not found => ${filePath}`)); + } else if (defaultFileName) { + createLogger(logLevel).error( + chalk.red(`File not found => ${defaultFileName}.{js,mjs,ts}`), + ); + } else { + createLogger(logLevel).error(chalk.red(`File not found`)); + } + process.exit(1); + } + + const normalizeResolvedPath = normalizeSafe(resolvedPath); + const cachedData = cache.get(resolvedPath); + + if (cachedData) { + return { + path: normalizeResolvedPath, + ...cachedData, + cached: true, + }; + } + + try { + let file: File | undefined; + + if (!file && !isTS && !isMjs) { + // 1. try to directly require the module (assuming commonjs) + try { + // clear cache in case of server restart + delete require.cache[require.resolve(resolvedPath)]; + + file = require(resolvedPath); + + debug(`cjs loaded in ${Date.now() - start}ms`); + } catch (e) { + const ignored = new RegExp( + [ + `Cannot use import statement`, + `Must use import to load ES Module`, + // #1635, #2050 some Node 12.x versions don't have esm detection + // so it throws normal syntax errors when encountering esm syntax + `Unexpected token`, + `Unexpected identifier`, + ].join('|'), + ); + //@ts-ignore + if (!ignored.test(e.message)) { + throw e; + } + } + } + + if (!file) { + // 2. if we reach here, the file is ts or using es import syntax, or + // the user has type: "module" in their package.json (#917) + // transpile es import syntax to require syntax using rollup. + // lazy require rollup (it's actually in dependencies) + const { code } = await bundleFile( + resolvedPath, + isMjs, + root || dirname(normalizeResolvedPath), + alias, + tsconfig?.compilerOptions, + ); + + if (load) { + file = await loadFromBundledFile(resolvedPath, code, isDefault); + } else { + file = code as any; + } + + debug(`bundled file loaded in ${Date.now() - start}ms`); + } + + cache.set(resolvedPath, { file }); + + return { + path: normalizeResolvedPath, + file, + }; + } catch (error: any) { + cache.set(resolvedPath, { error }); + + return { + path: normalizeResolvedPath, + error, + }; + } +} + +async function bundleFile( + fileName: string, + mjs = false, + workspace: string, + alias?: Record, + compilerOptions?: Tsconfig['compilerOptions'], +): Promise<{ code: string; dependencies: string[] }> { + const result = await build({ + absWorkingDir: process.cwd(), + entryPoints: [fileName], + outfile: 'out.js', + write: false, + platform: 'node', + bundle: true, + format: mjs ? 'esm' : 'cjs', + sourcemap: 'inline', + metafile: true, + target: 'es6', + minifyWhitespace: true, + plugins: [ + ...(alias || compilerOptions?.paths + ? [ + { + name: 'aliasing', + setup(build: PluginBuild) { + build.onResolve( + { filter: /^[\w@][^:]/ }, + async ({ path: id }) => { + if (alias) { + const matchKeys = Object.keys(alias); + const match = matchKeys.find( + (key) => + id.startsWith(key) || mm.isMatch(id, matchKeys), + ); + + if (match) { + const find = mm.scan(match); + const replacement = mm.scan(alias[match]); + + const base = resolve(workspace, replacement.base); + const newPath = find.base + ? id.replace(find.base, base) + : joinSafe(base, id); + + const ext = extname(newPath); + + const aliased = ext ? newPath : `${newPath}.ts`; + + if (!fs.existsSync(aliased)) { + return; + } + + return { + path: aliased, + }; + } + } + + if (compilerOptions?.paths) { + const matchKeys = Object.keys(compilerOptions?.paths); + const match = matchKeys.find( + (key) => + id.startsWith(key) || mm.isMatch(id, matchKeys), + ); + + if (match) { + const find = mm.scan(match); + const replacement = mm.scan( + compilerOptions?.paths[match][0], + ); + + const base = resolve(workspace, replacement.base); + const newPath = find.base + ? id.replace(find.base, base) + : joinSafe(base, id); + + const ext = extname(newPath); + + const aliased = ext ? newPath : `${newPath}.ts`; + + if (!fs.existsSync(aliased)) { + return; + } + + return { + path: aliased, + }; + } + } + }, + ); + }, + }, + ] + : []), + { + name: 'externalize-deps', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + const id = args.path; + if (id[0] !== '.' && !path.isAbsolute(id)) { + return { + external: true, + }; + } + }); + }, + }, + { + name: 'replace-import-meta', + setup(build) { + build.onLoad({ filter: /\.[jt]s$/ }, async (args) => { + const contents = await fs.promises.readFile(args.path, 'utf8'); + return { + loader: args.path.endsWith('.ts') ? 'ts' : 'js', + contents: contents + .replace( + /\bimport\.meta\.url\b/g, + JSON.stringify(`file://${args.path}`), + ) + .replace( + /\b__dirname\b/g, + JSON.stringify(path.dirname(args.path)), + ) + .replace(/\b__filename\b/g, JSON.stringify(args.path)), + }; + }); + }, + }, + ], + }); + const { text } = result.outputFiles[0]; + return { + code: text, + dependencies: result.metafile ? Object.keys(result.metafile.inputs) : [], + }; +} + +interface NodeModuleWithCompile extends NodeModule { + _compile(code: string, filename: string): any; +} + +async function loadFromBundledFile( + fileName: string, + bundledCode: string, + isDefault: boolean, +): Promise { + const extension = path.extname(fileName); + const defaultLoader = require.extensions[extension]!; + require.extensions[extension] = (module: NodeModule, filename: string) => { + if (filename === fileName) { + (module as NodeModuleWithCompile)._compile(bundledCode, filename); + } else { + defaultLoader(module, filename); + } + }; + // clear cache in case of server restart + delete require.cache[require.resolve(fileName)]; + const raw = require(fileName); + const file = isDefault && raw.__esModule ? raw.default : raw; + require.extensions[extension] = defaultLoader; + return file; +} + +export async function removeFiles(patterns: string[], dir: string) { + const files = await glob(patterns, { + cwd: dir, + absolute: true, + }); + await Promise.all(files.map((file) => fs.promises.unlink(file))); +} diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 000000000..c8d632037 --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,18 @@ +export * from './assertion'; +export * from './async-reduce'; +export * from './case'; +export * from './compare-version'; +export * from './debug'; +export * from './doc'; +export * from './dynamic-import'; +export * from './extension'; +export * from './file'; +export * from './logger'; +export * from './merge-deep'; +export * from './occurrence'; +export * from './open-api-converter'; +export * from './path'; +export * from './sort'; +export * from './string'; +export * from './tsconfig'; +export * from './validator'; diff --git a/packages/core/src/utils/logger.ts b/packages/core/src/utils/logger.ts new file mode 100644 index 000000000..809ef413a --- /dev/null +++ b/packages/core/src/utils/logger.ts @@ -0,0 +1,177 @@ +import chalk from 'chalk'; +import readline from 'readline'; +export const log = console.log; // tslint:disable-line:no-console + +export const startMessage = ({ + name, + version, + description, +}: { + name: string; + version: string; + description: string; +}) => + log( + `🍻 Start ${chalk.cyan.bold(name)} ${chalk.green(`v${version}`)}${ + description ? ` - ${description}` : '' + }`, + ); + +export const errorMessage = (err: string) => log(chalk.red(err)); + +export const mismatchArgsMessage = (mismatchArgs: string[]) => + log( + chalk.yellow( + `${mismatchArgs.join(', ')} ${ + mismatchArgs.length === 1 ? 'is' : 'are' + } not defined in your configuration!`, + ), + ); + +export const createSuccessMessage = (backend?: string) => + log( + `🎉 ${ + backend ? `${chalk.green(backend)} - ` : '' + }Your OpenAPI spec has been converted into ready to use orval!`, + ); + +export const ibmOpenapiValidatorWarnings = ( + warnings: { + path: string; + message: string; + }[], +) => { + log(chalk.yellow('(!) Warnings')); + + warnings.forEach((i) => + log(chalk.yellow(`Message : ${i.message}\nPath : ${i.path}`)), + ); +}; + +export const ibmOpenapiValidatorErrors = ( + errors: { + path: string; + message: string; + }[], +) => { + log(chalk.red('(!) Errors')); + + errors.forEach((i) => + log(chalk.red(`Message : ${i.message}\nPath : ${i.path}`)), + ); +}; + +export type LogType = 'error' | 'warn' | 'info'; +export type LogLevel = LogType | 'silent'; +export interface Logger { + info(msg: string, options?: LogOptions): void; + warn(msg: string, options?: LogOptions): void; + warnOnce(msg: string, options?: LogOptions): void; + error(msg: string, options?: LogOptions): void; + clearScreen(type: LogType): void; + hasWarned: boolean; +} + +export interface LogOptions { + clear?: boolean; + timestamp?: boolean; +} + +export const LogLevels: Record = { + silent: 0, + error: 1, + warn: 2, + info: 3, +}; + +let lastType: LogType | undefined; +let lastMsg: string | undefined; +let sameCount = 0; + +function clearScreen() { + const repeatCount = process.stdout.rows - 2; + const blank = repeatCount > 0 ? '\n'.repeat(repeatCount) : ''; + console.log(blank); + readline.cursorTo(process.stdout, 0, 0); + readline.clearScreenDown(process.stdout); +} + +export interface LoggerOptions { + prefix?: string; + allowClearScreen?: boolean; +} + +export function createLogger( + level: LogLevel = 'info', + options: LoggerOptions = {}, +): Logger { + const { prefix = '[vite]', allowClearScreen = true } = options; + + const thresh = LogLevels[level]; + const clear = + allowClearScreen && process.stdout.isTTY && !process.env.CI + ? clearScreen + : () => {}; + + function output(type: LogType, msg: string, options: LogOptions = {}) { + if (thresh >= LogLevels[type]) { + const method = type === 'info' ? 'log' : type; + const format = () => { + if (options.timestamp) { + const tag = + type === 'info' + ? chalk.cyan.bold(prefix) + : type === 'warn' + ? chalk.yellow.bold(prefix) + : chalk.red.bold(prefix); + return `${chalk.dim(new Date().toLocaleTimeString())} ${tag} ${msg}`; + } else { + return msg; + } + }; + if (type === lastType && msg === lastMsg) { + sameCount++; + clear(); + console[method](format(), chalk.yellow(`(x${sameCount + 1})`)); + } else { + sameCount = 0; + lastMsg = msg; + lastType = type; + if (options.clear) { + clear(); + } + console[method](format()); + } + } + } + + const warnedMessages = new Set(); + + const logger: Logger = { + hasWarned: false, + info(msg, opts) { + output('info', msg, opts); + }, + warn(msg, opts) { + logger.hasWarned = true; + output('warn', msg, opts); + }, + warnOnce(msg, opts) { + if (warnedMessages.has(msg)) return; + logger.hasWarned = true; + output('warn', msg, opts); + warnedMessages.add(msg); + }, + error(msg, opts) { + logger.hasWarned = true; + output('error', msg, opts); + }, + clearScreen(type) { + if (thresh >= LogLevels[type]) { + clear(); + } + }, + }; + + return logger; +} diff --git a/packages/core/src/utils/merge-deep.ts b/packages/core/src/utils/merge-deep.ts new file mode 100644 index 000000000..d5150447f --- /dev/null +++ b/packages/core/src/utils/merge-deep.ts @@ -0,0 +1,24 @@ +const isObject = (obj: unknown) => obj && typeof obj === 'object'; + +export function mergeDeep>( + source: T, + target: T, +): T { + if (!isObject(target) || !isObject(source)) { + return source; + } + + return Object.entries(target).reduce((acc, [key, value]) => { + const sourceValue = acc[key]; + + if (Array.isArray(sourceValue) && Array.isArray(value)) { + (acc[key] as any) = [...sourceValue, ...value]; + } else if (isObject(sourceValue) && isObject(value)) { + (acc[key] as any) = mergeDeep(sourceValue, value); + } else { + (acc[key] as any) = value; + } + + return acc; + }, source); +} diff --git a/packages/core/src/utils/occurrence.ts b/packages/core/src/utils/occurrence.ts new file mode 100644 index 000000000..1c79911f7 --- /dev/null +++ b/packages/core/src/utils/occurrence.ts @@ -0,0 +1,7 @@ +export const count = (str: string = '', key: string) => { + if (!str) { + return 0; + } + + return (str.match(new RegExp(key, 'g')) ?? []).length; +}; diff --git a/packages/core/src/utils/open-api-converter.ts b/packages/core/src/utils/open-api-converter.ts new file mode 100644 index 000000000..4af82f289 --- /dev/null +++ b/packages/core/src/utils/open-api-converter.ts @@ -0,0 +1,29 @@ +import chalk from 'chalk'; +import { OpenAPIObject } from 'openapi3-ts'; +import swagger2openapi from 'swagger2openapi'; +import { log } from './logger'; + +export const openApiConverter = async ( + schema: any, + options: swagger2openapi.Options = {}, + specKey: string, +): Promise => { + try { + return new Promise((resolve) => { + if (!schema.openapi && schema.swagger === '2.0') { + swagger2openapi.convertObj(schema, options, (err, value) => { + if (err) { + log(chalk.yellow(`${specKey}\n=> ${err}`)); + resolve(schema); + } else { + resolve(value.openapi); + } + }); + } else { + resolve(schema); + } + }); + } catch (e) { + throw `Oups... 🍻.\nPath: ${specKey}\nParsing Error: ${e}`; + } +}; diff --git a/packages/core/src/utils/path.ts b/packages/core/src/utils/path.ts new file mode 100644 index 000000000..0de019391 --- /dev/null +++ b/packages/core/src/utils/path.ts @@ -0,0 +1,40 @@ +import { + normalize, + normalizeSafe, + relative as normalizedRelative, + sep as seperator, +} from 'upath'; +import { isUrl } from './assertion'; +import { getExtension } from './extension'; +import { getFileInfo } from './file'; + +/** + * Behaves exactly like `path.relative(from, to)`, but keeps the first meaningful "./" + */ +export const relativeSafe = (from: string, to: string) => { + const normalizedRelativePath = normalizedRelative(from, to); + /** + * Prepend "./" to every path and then use normalizeSafe method to normalize it + * normalizeSafe doesn't remove meaningful leading "./" + */ + const relativePath = normalizeSafe(`.${seperator}${normalizedRelativePath}`); + return relativePath; +}; + +export const getSpecName = (specKey: string, target: string) => { + if (isUrl(specKey)) { + const url = new URL(target); + return specKey + .replace(url.origin, '') + .replace(getFileInfo(url.pathname).dirname, '') + .replace(`.${getExtension(specKey)}`, ''); + } + + return ( + '/' + + normalize(normalizedRelative(getFileInfo(target).dirname, specKey)) + .split('../') + .join('') + .replace(`.${getExtension(specKey)}`, '') + ); +}; diff --git a/packages/core/src/utils/sort.ts b/packages/core/src/utils/sort.ts new file mode 100644 index 000000000..e0b527616 --- /dev/null +++ b/packages/core/src/utils/sort.ts @@ -0,0 +1,25 @@ +export const sortByPriority = ( + arr: (T & { default?: boolean; required?: boolean })[], +) => + arr.sort((a, b) => { + if (a.default) { + return 1; + } + + if (b.default) { + return -1; + } + + if (a.required && b.required) { + return 0; + } + + if (a.required) { + return -1; + } + + if (b.required) { + return 1; + } + return 0; + }); diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts new file mode 100644 index 000000000..8e3e38874 --- /dev/null +++ b/packages/core/src/utils/string.ts @@ -0,0 +1,141 @@ +import { keyword } from 'esutils'; +import get from 'lodash.get'; +import { + isBoolean, + isFunction, + isNull, + isNumber, + isString, + isUndefined, +} from './assertion'; + +export const stringify = ( + data?: string | any[] | { [key: string]: any }, +): string | undefined => { + if (isUndefined(data) || isNull(data)) { + return; + } + + if (isString(data)) { + return `'${data}'`; + } + + if (isNumber(data) || isBoolean(data) || isFunction(data)) { + return `${data}`; + } + + if (Array.isArray(data)) { + return `[${data.map(stringify).join(', ')}]`; + } + + return Object.entries(data).reduce((acc, [key, value], index, arr) => { + const strValue = stringify(value); + if (arr.length === 1) { + return `{ ${key}: ${strValue}, }`; + } + + if (!index) { + return `{ ${key}: ${strValue}, `; + } + + if (arr.length - 1 === index) { + return acc + `${key}: ${strValue}, }`; + } + + return acc + `${key}: ${strValue}, `; + }, ''); +}; + +export const sanitize = ( + value: string, + options?: { + whitespace?: string | true; + underscore?: string | true; + dot?: string | true; + dash?: string | true; + es5keyword?: boolean; + es5IdentifierName?: boolean; + special?: boolean; + }, +) => { + const { + whitespace = '', + underscore = '', + dot = '', + dash = '', + es5keyword = false, + es5IdentifierName = false, + special = false, + } = options ?? {}; + let newValue = value; + + if (special !== true) { + newValue = newValue.replace( + /[!"`'#%&,:;<>=@{}~\$\(\)\*\+\/\\\?\[\]\^\|]/g, + '', + ); + } + + if (whitespace !== true) { + newValue = newValue.replace(/[\s]/g, whitespace); + } + + if (underscore !== true) { + newValue = newValue.replace(/['_']/g, underscore); + } + + if (dot !== true) { + newValue = newValue.replace(/[.]/g, dot); + } + + if (dash !== true) { + newValue = newValue.replace(/[-]/g, dash); + } + + if (es5keyword) { + newValue = keyword.isKeywordES5(newValue, true) ? `_${newValue}` : newValue; + } + + if (es5IdentifierName) { + if (newValue.match(/^[0-9]/)) { + newValue = `N${newValue}`; + } else { + newValue = keyword.isIdentifierNameES5(newValue) + ? newValue + : `_${newValue}`; + } + } + + return newValue; +}; + +export const toObjectString = (props: T[], path?: keyof T) => { + if (!props.length) { + return ''; + } + + const arrayOfString = path ? props.map((prop) => get(prop, path)) : props; + + return arrayOfString.join(',\n ') + ','; +}; + +const NUMBERS = { + '0': 'zero', + '1': 'one', + '2': 'two', + '3': 'three', + '4': 'four', + '5': 'five', + '6': 'six', + '7': 'seven', + '8': 'eight', + '9': 'nine', +}; + +export const getNumberWord = (num: number) => { + const arrayOfNumber = num.toString().split('') as (keyof typeof NUMBERS)[]; + return arrayOfNumber.reduce((acc, n) => acc + NUMBERS[n], ''); +}; + +export const escape = (str: string, char: string = "'") => + str.replace(char, `\\${char}`); diff --git a/packages/core/src/utils/tsconfig.ts b/packages/core/src/utils/tsconfig.ts new file mode 100644 index 000000000..903853de6 --- /dev/null +++ b/packages/core/src/utils/tsconfig.ts @@ -0,0 +1,12 @@ +import { Tsconfig } from '../types'; + +export const isSyntheticDefaultImportsAllow = (config?: Tsconfig) => { + if (!config) { + return true; + } + + return !!( + config?.compilerOptions?.allowSyntheticDefaultImports ?? + config?.compilerOptions?.esModuleInterop + ); +}; diff --git a/packages/core/src/utils/validator.ts b/packages/core/src/utils/validator.ts new file mode 100644 index 000000000..74989ecf8 --- /dev/null +++ b/packages/core/src/utils/validator.ts @@ -0,0 +1,23 @@ +import openApiValidator from 'ibm-openapi-validator'; +import { OpenAPIObject } from 'openapi3-ts'; +import { + ibmOpenapiValidatorErrors, + ibmOpenapiValidatorWarnings, +} from './logger'; + +/** + * Validate the spec with ibm-openapi-validator (with a custom pretty logger). + * More information: https://github.com/IBM/openapi-validator/#configuration + * @param specs openAPI spec + */ +export const ibmOpenapiValidator = async (specs: OpenAPIObject) => { + const { errors, warnings } = await openApiValidator(specs); + + if (warnings.length) { + ibmOpenapiValidatorWarnings(warnings); + } + + if (errors.length) { + ibmOpenapiValidatorErrors(errors); + } +}; diff --git a/packages/core/src/writers/index.ts b/packages/core/src/writers/index.ts new file mode 100644 index 000000000..4ba8040e8 --- /dev/null +++ b/packages/core/src/writers/index.ts @@ -0,0 +1,7 @@ +export * from './schemas'; +export * from './single-mode'; +export * from './split-mode'; +export * from './split-tags-mode'; +export * from './tags-mode'; +export * from './target'; +export * from './target-tags'; diff --git a/packages/core/src/writers/schemas.ts b/packages/core/src/writers/schemas.ts new file mode 100644 index 000000000..df2649766 --- /dev/null +++ b/packages/core/src/writers/schemas.ts @@ -0,0 +1,128 @@ +import { ensureFile, outputFile, readFile, writeFile } from 'fs-extra'; +import { join } from 'upath'; +import { generateImports } from '../generators'; +import { GeneratorSchema } from '../types'; +import { camel } from '../utils'; + +const getSchema = ({ + schema: { imports, model }, + target, + isRootKey, + specsName, + header, +}: { + schema: GeneratorSchema; + target: string; + isRootKey: boolean; + specsName: Record; + header: string; +}): string => { + let file = header; + file += generateImports({ + imports: imports.filter( + (imp) => + !model.includes(`type ${imp.alias || imp.name} =`) && + !model.includes(`interface ${imp.alias || imp.name} {`), + ), + target, + isRootKey, + specsName, + }); + file += imports.length ? '\n\n' : '\n'; + file += model; + return file; +}; + +const getPath = (path: string, name: string): string => + join(path, `/${name}.ts`); + +export const writeModelInline = (acc: string, model: string): string => + acc + `${model}\n`; + +export const writeModelsInline = (array: GeneratorSchema[]): string => + array.reduce((acc, { model }) => writeModelInline(acc, model), ''); + +export const writeSchema = async ({ + path, + schema, + target, + isRootKey, + specsName, + header, +}: { + path: string; + schema: GeneratorSchema; + target: string; + isRootKey: boolean; + specsName: Record; + header: string; +}) => { + const name = camel(schema.name); + try { + await outputFile( + getPath(path, name), + getSchema({ schema, target, isRootKey, specsName, header }), + ); + } catch (e) { + throw `Oups... 🍻. An Error occurred while writing schema ${name} => ${e}`; + } +}; + +export const writeSchemas = async ({ + schemaPath, + schemas, + target, + isRootKey, + specsName, + header, +}: { + schemaPath: string; + schemas: GeneratorSchema[]; + target: string; + isRootKey: boolean; + specsName: Record; + header: string; +}) => { + const schemaFilePath = join(schemaPath, '/index.ts'); + await ensureFile(schemaFilePath); + + await Promise.all( + schemas.map((schema) => + writeSchema({ + path: schemaPath, + schema, + target, + isRootKey, + specsName, + header, + }), + ), + ); + + try { + const data = await readFile(schemaFilePath); + + const stringData = data.toString(); + + const importStatements = schemas + .filter((schema) => { + return ( + !stringData.includes(`export * from './${camel(schema.name)}'`) && + !stringData.includes(`export * from "./${camel(schema.name)}"`) + ); + }) + .map((schema) => `export * from './${camel(schema.name)}';`); + + const currentFileExports = (stringData + .match(/export \* from(.*)('|")/g) + ?.map((s) => s + ';') ?? []) as string[]; + + const fileContent = [...currentFileExports, ...importStatements] + .sort() + .join('\n'); + + await writeFile(schemaFilePath, fileContent); + } catch (e) { + throw `Oups... 🍻. An Error occurred while writing schema index file ${schemaFilePath} => ${e}`; + } +}; diff --git a/packages/core/src/writers/single-mode.ts b/packages/core/src/writers/single-mode.ts new file mode 100644 index 000000000..7b4d4a775 --- /dev/null +++ b/packages/core/src/writers/single-mode.ts @@ -0,0 +1,104 @@ +import { outputFile } from 'fs-extra'; +import { generateModelsInline, generateMutatorImports } from '../generators'; +import { WriteModeProps } from '../types'; +import { + camel, + getFileInfo, + isSyntheticDefaultImportsAllow, + relativeSafe, +} from '../utils'; +import { generateTarget } from './target'; + +export const writeSingleMode = async ({ + builder, + output, + specsName, + header, +}: WriteModeProps): Promise => { + try { + const { path, dirname } = getFileInfo(output.target, { + backupFilename: camel(builder.info.title), + }); + + const { + imports, + importsMSW, + implementation, + implementationMSW, + mutators, + formData, + formUrlEncoded, + } = generateTarget(builder, output); + + let data = header; + + const schemasPath = output.schemas + ? relativeSafe(dirname, getFileInfo(output.schemas).dirname) + : undefined; + + const isAllowSyntheticDefaultImports = isSyntheticDefaultImportsAllow( + output.tsconfig, + ); + + data += builder.imports({ + client: output.client, + implementation, + imports: schemasPath + ? [ + { + exports: imports.filter( + (imp) => !importsMSW.some((impMSW) => imp.name === impMSW.name), + ), + dependency: schemasPath, + }, + ] + : [], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + hasGlobalMutator: !!output.override.mutator, + packageJson: output.packageJson, + }); + + if (output.mock) { + data += builder.importsMock({ + implementation: implementationMSW, + imports: schemasPath + ? [{ exports: importsMSW, dependency: schemasPath }] + : [], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + }); + } + + if (mutators) { + data += generateMutatorImports({ mutators, implementation }); + } + + if (formData) { + data += generateMutatorImports({ mutators: formData }); + } + + if (formUrlEncoded) { + data += generateMutatorImports({ mutators: formUrlEncoded }); + } + + if (!output.schemas) { + data += generateModelsInline(builder.schemas); + } + + data += `\n\n${implementation}`; + + if (output.mock) { + data += '\n\n'; + data += implementationMSW; + } + + await outputFile(path, data); + + return [path]; + } catch (e) { + throw `Oups... 🍻. An Error occurred while writing file => ${e}`; + } +}; diff --git a/packages/core/src/writers/split-mode.ts b/packages/core/src/writers/split-mode.ts new file mode 100644 index 000000000..77a00f913 --- /dev/null +++ b/packages/core/src/writers/split-mode.ts @@ -0,0 +1,125 @@ +import { outputFile } from 'fs-extra'; +import { join } from 'upath'; +import { generateModelsInline, generateMutatorImports } from '../generators'; +import { OutputClient, WriteModeProps } from '../types'; +import { + camel, + getFileInfo, + isSyntheticDefaultImportsAllow, + relativeSafe, +} from '../utils'; +import { generateTarget } from './target'; + +export const writeSplitMode = async ({ + builder, + output, + specsName, + header, +}: WriteModeProps): Promise => { + try { + const { filename, dirname, extension } = getFileInfo(output.target, { + backupFilename: camel(builder.info.title), + }); + + const { + imports, + implementation, + implementationMSW, + importsMSW, + mutators, + formData, + formUrlEncoded, + } = generateTarget(builder, output); + + let implementationData = header; + let mswData = header; + + const relativeSchemasPath = output.schemas + ? relativeSafe(dirname, getFileInfo(output.schemas).dirname) + : './' + filename + '.schemas'; + + const isAllowSyntheticDefaultImports = isSyntheticDefaultImportsAllow( + output.tsconfig, + ); + + implementationData += builder.imports({ + client: output.client, + implementation, + imports: [{ exports: imports, dependency: relativeSchemasPath }], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + hasGlobalMutator: !!output.override.mutator, + packageJson: output.packageJson, + }); + mswData += builder.importsMock({ + implementation: implementationMSW, + imports: [ + { + exports: importsMSW, + dependency: relativeSchemasPath, + }, + ], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + }); + + const schemasPath = !output.schemas + ? join(dirname, filename + '.schemas' + extension) + : undefined; + + if (schemasPath) { + const schemasData = header + generateModelsInline(builder.schemas); + + await outputFile( + join(dirname, filename + '.schemas' + extension), + schemasData, + ); + } + + if (mutators) { + implementationData += generateMutatorImports({ + mutators, + implementation, + }); + } + + if (formData) { + implementationData += generateMutatorImports({ mutators: formData }); + } + + if (formUrlEncoded) { + implementationData += generateMutatorImports({ + mutators: formUrlEncoded, + }); + } + + implementationData += `\n${implementation}`; + mswData += `\n${implementationMSW}`; + + const implementationFilename = + filename + + (OutputClient.ANGULAR === output.client ? '.service' : '') + + extension; + + const implementationPath = join(dirname, implementationFilename); + await outputFile(join(dirname, implementationFilename), implementationData); + + const mockPath = output.mock + ? join(dirname, filename + '.msw' + extension) + : undefined; + + if (mockPath) { + await outputFile(mockPath, mswData); + } + + return [ + implementationPath, + ...(schemasPath ? [schemasPath] : []), + ...(mockPath ? [mockPath] : []), + ]; + } catch (e) { + throw `Oups... 🍻. An Error occurred while splitting => ${e}`; + } +}; diff --git a/packages/core/src/writers/split-tags-mode.ts b/packages/core/src/writers/split-tags-mode.ts new file mode 100644 index 000000000..8907907ff --- /dev/null +++ b/packages/core/src/writers/split-tags-mode.ts @@ -0,0 +1,134 @@ +import { outputFile } from 'fs-extra'; +import { join } from 'upath'; +import { generateModelsInline, generateMutatorImports } from '../generators'; +import { OutputClient, WriteModeProps } from '../types'; +import { + camel, + getFileInfo, + isSyntheticDefaultImportsAllow, + relativeSafe, +} from '../utils'; +import { generateTargetForTags } from './target-tags'; + +export const writeSplitTagsMode = async ({ + builder, + output, + specsName, + header, +}: WriteModeProps): Promise => { + const { filename, dirname, extension } = getFileInfo(output.target, { + backupFilename: camel(builder.info.title), + }); + + const target = generateTargetForTags(builder, output); + + const isAllowSyntheticDefaultImports = isSyntheticDefaultImportsAllow( + output.tsconfig, + ); + + const generatedFilePathsArray = await Promise.all( + Object.entries(target).map(async ([tag, target]) => { + try { + const { + imports, + implementation, + implementationMSW, + importsMSW, + mutators, + formData, + formUrlEncoded, + } = target; + + let implementationData = header; + let mswData = header; + + const relativeSchemasPath = output.schemas + ? '../' + relativeSafe(dirname, getFileInfo(output.schemas).dirname) + : '../' + filename + '.schemas'; + + implementationData += builder.imports({ + client: output.client, + implementation, + imports: [{ exports: imports, dependency: relativeSchemasPath }], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + hasGlobalMutator: !!output.override.mutator, + packageJson: output.packageJson, + }); + mswData += builder.importsMock({ + implementation: implementationMSW, + imports: [ + { + exports: importsMSW, + dependency: relativeSchemasPath, + }, + ], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + }); + + const schemasPath = !output.schemas + ? join(dirname, filename + '.schemas' + extension) + : undefined; + + if (schemasPath) { + const schemasData = header + generateModelsInline(builder.schemas); + + await outputFile(schemasPath, schemasData); + } + + if (mutators) { + implementationData += generateMutatorImports({ + mutators, + implementation, + oneMore: true, + }); + } + + if (formData) { + implementationData += generateMutatorImports({ + mutators: formData, + oneMore: true, + }); + } + if (formUrlEncoded) { + implementationData += generateMutatorImports({ + mutators: formUrlEncoded, + oneMore: true, + }); + } + + implementationData += `\n${implementation}`; + mswData += `\n${implementationMSW}`; + + const implementationFilename = + tag + + (OutputClient.ANGULAR === output.client ? '.service' : '') + + extension; + + const implementationPath = join(dirname, tag, implementationFilename); + await outputFile(implementationPath, implementationData); + + const mockPath = output.mock + ? join(dirname, tag, tag + '.msw' + extension) + : undefined; + + if (mockPath) { + await outputFile(mockPath, mswData); + } + + return [ + implementationPath, + ...(schemasPath ? [schemasPath] : []), + ...(mockPath ? [mockPath] : []), + ]; + } catch (e) { + throw `Oups... 🍻. An Error occurred while splitting tag ${tag} => ${e}`; + } + }), + ); + + return generatedFilePathsArray.flatMap((it) => it); +}; diff --git a/packages/core/src/writers/tags-mode.ts b/packages/core/src/writers/tags-mode.ts new file mode 100644 index 000000000..1104d9345 --- /dev/null +++ b/packages/core/src/writers/tags-mode.ts @@ -0,0 +1,119 @@ +import { outputFile } from 'fs-extra'; +import { join } from 'upath'; +import { generateModelsInline, generateMutatorImports } from '../generators'; +import { WriteModeProps } from '../types'; +import { + camel, + getFileInfo, + isSyntheticDefaultImportsAllow, + kebab, + relativeSafe, +} from '../utils'; +import { generateTargetForTags } from './target-tags'; + +export const writeTagsMode = async ({ + builder, + output, + specsName, + header, +}: WriteModeProps): Promise => { + const { filename, dirname, extension } = getFileInfo(output.target, { + backupFilename: camel(builder.info.title), + }); + + const target = generateTargetForTags(builder, output); + + const isAllowSyntheticDefaultImports = isSyntheticDefaultImportsAllow( + output.tsconfig, + ); + + const generatedFilePathsArray = await Promise.all( + Object.entries(target).map(async ([tag, target]) => { + try { + const { + imports, + implementation, + implementationMSW, + importsMSW, + mutators, + formData, + formUrlEncoded, + } = target; + + let data = header; + + const schemasPathRelative = output.schemas + ? relativeSafe(dirname, getFileInfo(output.schemas).dirname) + : './' + filename + '.schemas'; + + data += builder.imports({ + client: output.client, + implementation, + imports: [ + { + exports: imports.filter( + (imp) => !importsMSW.some((impMSW) => imp.name === impMSW.name), + ), + dependency: schemasPathRelative, + }, + ], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + hasGlobalMutator: !!output.override.mutator, + packageJson: output.packageJson, + }); + + if (output.mock) { + data += builder.importsMock({ + implementation: implementationMSW, + imports: [{ exports: importsMSW, dependency: schemasPathRelative }], + specsName, + hasSchemaDir: !!output.schemas, + isAllowSyntheticDefaultImports, + }); + } + + const schemasPath = !output.schemas + ? join(dirname, filename + '.schemas' + extension) + : undefined; + + if (schemasPath) { + const schemasData = header + generateModelsInline(builder.schemas); + + await outputFile(schemasPath, schemasData); + } + + if (mutators) { + data += generateMutatorImports({ mutators, implementation }); + } + + if (formData) { + data += generateMutatorImports({ mutators: formData }); + } + + if (formUrlEncoded) { + data += generateMutatorImports({ mutators: formUrlEncoded }); + } + + data += '\n\n'; + data += implementation; + + if (output.mock) { + data += '\n\n'; + + data += implementationMSW; + } + + const implementationPath = join(dirname, `${kebab(tag)}${extension}`); + await outputFile(implementationPath, data); + + return [implementationPath, ...(schemasPath ? [schemasPath] : [])]; + } catch (e) { + throw `Oups... 🍻. An Error occurred while writing tag ${tag} => ${e}`; + } + }), + ); + + return generatedFilePathsArray.flatMap((it) => it); +}; diff --git a/packages/core/src/writers/target-tags.ts b/packages/core/src/writers/target-tags.ts new file mode 100644 index 000000000..ca281b79e --- /dev/null +++ b/packages/core/src/writers/target-tags.ts @@ -0,0 +1,162 @@ +import { + GeneratorOperation, + GeneratorTarget, + GeneratorTargetFull, + NormalizedOutputOptions, + OutputClient, + WriteSpecsBuilder, +} from '../types'; +import { compareVersions, kebab, pascal } from '../utils'; + +const addDefaultTagIfEmpty = (operation: GeneratorOperation) => ({ + ...operation, + tags: operation.tags.length ? operation.tags : ['default'], +}); + +const generateTargetTags = ( + currentAcc: { [key: string]: GeneratorTargetFull }, + operation: GeneratorOperation, +): { + [key: string]: GeneratorTargetFull; +} => { + return operation.tags.map(kebab).reduce((acc, tag) => { + const currentOperation = acc[tag]; + + acc[tag] = !currentOperation + ? { + imports: operation.imports, + importsMSW: operation.importsMSW, + mutators: operation.mutator ? [operation.mutator] : [], + formData: operation.formData ? [operation.formData] : [], + formUrlEncoded: operation.formUrlEncoded + ? [operation.formUrlEncoded] + : [], + implementation: operation.implementation, + implementationMSW: { + function: operation.implementationMSW.function, + handler: operation.implementationMSW.handler, + }, + } + : { + implementation: + currentOperation.implementation + operation.implementation, + imports: [...currentOperation.imports, ...operation.imports], + importsMSW: [...currentOperation.importsMSW, ...operation.importsMSW], + implementationMSW: { + function: + currentOperation.implementationMSW.function + + operation.implementationMSW.function, + handler: + currentOperation.implementationMSW.handler + + operation.implementationMSW.handler, + }, + mutators: operation.mutator + ? [...(currentOperation.mutators ?? []), operation.mutator] + : currentOperation.mutators, + formData: operation.formData + ? [...(currentOperation.formData ?? []), operation.formData] + : currentOperation.formData, + formUrlEncoded: operation.formUrlEncoded + ? [ + ...(currentOperation.formUrlEncoded ?? []), + operation.formUrlEncoded, + ] + : currentOperation.formUrlEncoded, + }; + + return acc; + }, currentAcc); +}; + +export const generateTargetForTags = ( + builder: WriteSpecsBuilder, + options: NormalizedOutputOptions, +) => { + const isAngularClient = options.client === OutputClient.ANGULAR; + + const allTargetTags = Object.values(builder.operations) + .map(addDefaultTagIfEmpty) + .reduce((acc, operation, index, arr) => { + const targetTags = generateTargetTags(acc, operation); + + if (index === arr.length - 1) { + return Object.entries(targetTags).reduce< + Record + >((acc, [tag, target]) => { + const isMutator = !!target.mutators?.some((mutator) => + isAngularClient ? mutator.hasThirdArg : mutator.hasSecondArg, + ); + const operationNames = Object.values(builder.operations) + .filter(({ tags }) => tags.includes(tag)) + .map(({ operationName }) => operationName); + + const typescriptVersion = + options.packageJson?.dependencies?.['typescript'] ?? + options.packageJson?.devDependencies?.['typescript'] ?? + '4.4.0'; + + const hasAwaitedType = compareVersions(typescriptVersion, '4.5.0'); + + const titles = builder.title({ + outputClient: options.client, + title: pascal(tag), + customTitleFunc: options.override.title, + }); + + const footer = builder.footer({ + outputClient: options?.client, + operationNames, + hasMutator: !!target.mutators?.length, + hasAwaitedType, + titles, + }); + + const header = builder.header({ + outputClient: options.client, + isRequestOptions: options.override.requestOptions !== false, + isMutator, + isGlobalMutator: !!options.override.mutator, + provideIn: options.override.angular.provideIn, + hasAwaitedType, + titles, + }); + + acc[tag] = { + implementation: + header.implementation + + target.implementation + + footer.implementation, + implementationMSW: { + function: target.implementationMSW.function, + handler: + header.implementationMSW + + target.implementationMSW.handler + + footer.implementationMSW, + }, + imports: target.imports, + importsMSW: target.importsMSW, + mutators: target.mutators, + formData: target.formData, + formUrlEncoded: target.formUrlEncoded, + }; + + return acc; + }, {}); + } + + return targetTags; + }, {} as { [key: string]: GeneratorTargetFull }); + + return Object.entries(allTargetTags).reduce>( + (acc, [tag, target]) => { + acc[tag] = { + ...target, + implementationMSW: + target.implementationMSW.function + target.implementationMSW.handler, + }; + + return acc; + }, + {}, + ); +}; diff --git a/packages/core/src/writers/target.ts b/packages/core/src/writers/target.ts new file mode 100644 index 000000000..293a25852 --- /dev/null +++ b/packages/core/src/writers/target.ts @@ -0,0 +1,99 @@ +import { + GeneratorTarget, + GeneratorTargetFull, + NormalizedOutputOptions, + OutputClient, + WriteSpecsBuilder, +} from '../types'; +import { compareVersions, pascal } from '../utils'; + +export const generateTarget = ( + builder: WriteSpecsBuilder, + options: NormalizedOutputOptions, +): GeneratorTarget => { + const operationNames = Object.values(builder.operations).map( + ({ operationName }) => operationName, + ); + const isAngularClient = options?.client === OutputClient.ANGULAR; + + const titles = builder.title({ + outputClient: options.client, + title: pascal(builder.info.title), + customTitleFunc: options.override.title, + }); + + const target = Object.values(builder.operations).reduce( + (acc, operation, index, arr) => { + acc.imports.push(...operation.imports); + acc.importsMSW.push(...operation.importsMSW); + acc.implementation += operation.implementation + '\n'; + acc.implementationMSW.function += operation.implementationMSW.function; + acc.implementationMSW.handler += operation.implementationMSW.handler; + if (operation.mutator) { + acc.mutators.push(operation.mutator); + } + + if (operation.formData) { + acc.formData.push(operation.formData); + } + if (operation.formUrlEncoded) { + acc.formUrlEncoded.push(operation.formUrlEncoded); + } + + if (index === arr.length - 1) { + const isMutator = acc.mutators.some((mutator) => + isAngularClient ? mutator.hasThirdArg : mutator.hasSecondArg, + ); + + const typescriptVersion = + options.packageJson?.dependencies?.['typescript'] ?? + options.packageJson?.devDependencies?.['typescript'] ?? + '4.4.0'; + + const hasAwaitedType = compareVersions(typescriptVersion, '4.5.0'); + + const header = builder.header({ + outputClient: options.client, + isRequestOptions: options.override.requestOptions !== false, + isMutator, + isGlobalMutator: !!options.override.mutator, + provideIn: options.override.angular.provideIn, + hasAwaitedType, + titles, + }); + acc.implementation = header.implementation + acc.implementation; + acc.implementationMSW.handler = + header.implementationMSW + acc.implementationMSW.handler; + + const footer = builder.footer({ + outputClient: options?.client, + operationNames, + hasMutator: !!acc.mutators.length, + hasAwaitedType, + titles, + }); + acc.implementation += footer.implementation; + acc.implementationMSW.handler += footer.implementationMSW; + } + return acc; + }, + { + imports: [], + implementation: '', + implementationMSW: { + function: '', + handler: '', + }, + importsMSW: [], + mutators: [], + formData: [], + formUrlEncoded: [], + } as Required, + ); + + return { + ...target, + implementationMSW: + target.implementationMSW.function + target.implementationMSW.handler, + }; +}; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..b299c4337 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "../../types/**.d.ts"] +} diff --git a/packages/msw/README.md b/packages/msw/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/msw/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/msw/package.json b/packages/msw/package.json new file mode 100644 index 000000000..bcc63e74b --- /dev/null +++ b/packages/msw/package.json @@ -0,0 +1,22 @@ +{ + "name": "@orval/msw", + "version": "6.11.0-alpha.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts" + }, + "dependencies": { + "@orval/core": "6.11.0-alpha.1", + "cuid": "^2.1.8", + "openapi3-ts": "^3.0.0", + "lodash.get": "^4.4.2", + "lodash.omit": "^4.5.0" + } +} diff --git a/packages/msw/src/constants.ts b/packages/msw/src/constants.ts new file mode 100644 index 000000000..f45021869 --- /dev/null +++ b/packages/msw/src/constants.ts @@ -0,0 +1,28 @@ +import { SchemaObject } from 'openapi3-ts'; + +export const DEFAULT_FORMAT_MOCK: Record< + Required['format'], + string +> = { + bic: 'faker.finance.bic()', + city: 'faker.address.city()', + country: 'faker.address.country()', + date: "faker.date.past().toISOString().split('T')[0]", + 'date-time': "`${faker.date.past().toISOString().split('.')[0]}Z`", + email: 'faker.internet.email()', + firstName: 'faker.name.firstName()', + gender: 'faker.name.gender()', + iban: 'faker.finance.iban()', + ipv4: 'faker.internet.ipv4()', + ipv6: 'faker.internet.ipv6()', + jobTitle: 'faker.name.jobTitle()', + lastName: 'faker.name.lastName()', + password: 'faker.internet.password()', + phoneNumber: 'faker.phone.phoneNumber()', + streetName: 'faker.address.streetName()', + uri: 'faker.internet.url()', + url: 'faker.internet.url()', + userName: 'faker.internet.userName()', + uuid: 'faker.datatype.uuid()', + zipCode: 'faker.address.zipCode()', +}; diff --git a/packages/msw/src/getters/combine.ts b/packages/msw/src/getters/combine.ts new file mode 100644 index 000000000..e44805d1d --- /dev/null +++ b/packages/msw/src/getters/combine.ts @@ -0,0 +1,138 @@ +import { + ContextSpecs, + GeneratorImport, + isReference, + MockOptions, +} from '@orval/core'; +import omit from 'lodash.omit'; +import { resolveMockValue } from '../resolvers'; +import { MockSchemaObject } from '../types'; + +export const combineSchemasMock = ({ + item, + separator, + mockOptions, + operationId, + tags, + combine, + context, + imports, +}: { + item: MockSchemaObject; + separator: 'allOf' | 'oneOf' | 'anyOf'; + operationId: string; + mockOptions?: MockOptions; + tags: string[]; + combine?: { + separator: 'allOf' | 'oneOf' | 'anyOf'; + includedProperties: string[]; + }; + context: ContextSpecs; + imports: GeneratorImport[]; +}) => { + let combineImports: GeneratorImport[] = []; + let includedProperties: string[] = (combine?.includedProperties ?? []).slice( + 0, + ); + const itemResolvedValue = + isReference(item) || item.properties + ? resolveMockValue({ + schema: omit(item, separator) as MockSchemaObject, + combine: { + separator: 'allOf', + includedProperties: [], + }, + mockOptions, + operationId, + tags, + context, + imports, + }) + : undefined; + + includedProperties.push(...(itemResolvedValue?.includedProperties ?? [])); + combineImports.push(...(itemResolvedValue?.imports ?? [])); + + const value = (item[separator] ?? []).reduce((acc, val, index, arr) => { + const resolvedValue = resolveMockValue({ + schema: { + ...val, + name: item.name, + path: item.path ? item.path : '#', + }, + combine: { + separator, + includedProperties: + separator !== 'oneOf' + ? includedProperties + : itemResolvedValue?.includedProperties ?? [], + }, + mockOptions, + operationId, + tags, + context, + imports, + }); + + combineImports.push(...resolvedValue.imports); + includedProperties.push(...(resolvedValue.includedProperties ?? [])); + + const isLastElement = index === arr.length - 1; + + let currentValue = resolvedValue.value; + + if (itemResolvedValue?.value && separator === 'oneOf') { + currentValue = `${resolvedValue.value.slice(0, -1)},${ + itemResolvedValue.value + }}`; + } + + if (itemResolvedValue?.value && separator !== 'oneOf' && isLastElement) { + currentValue = `${currentValue}${ + itemResolvedValue?.value ? `,${itemResolvedValue.value}` : '' + }`; + } + + const isObjectBounds = + !combine || (combine.separator === 'oneOf' && separator === 'allOf'); + + if (!index && isObjectBounds) { + if (resolvedValue.enums || separator === 'oneOf') { + if (arr.length === 1) { + return `faker.helpers.arrayElement([${currentValue}])`; + } + return `faker.helpers.arrayElement([${currentValue},`; + } + + if (arr.length === 1) { + if (resolvedValue.type !== 'object') { + return currentValue; + } + return `{${currentValue}}`; + } + + return `{${currentValue},`; + } + + if (isLastElement) { + if (resolvedValue.enums || separator === 'oneOf') { + return `${acc}${currentValue}${!combine ? '])' : ''}`; + } + + return `${acc}${currentValue}${isObjectBounds ? '}' : ''}`; + } + + if (!currentValue) { + return acc; + } + + return `${acc}${currentValue},`; + }, ''); + + return { + value, + imports: combineImports, + name: item.name, + includedProperties, + }; +}; diff --git a/packages/msw/src/getters/index.ts b/packages/msw/src/getters/index.ts new file mode 100644 index 000000000..b766499b4 --- /dev/null +++ b/packages/msw/src/getters/index.ts @@ -0,0 +1,4 @@ +export * from './combine'; +export * from './object'; +export * from './route'; +export * from './scalar'; diff --git a/packages/msw/src/getters/object.ts b/packages/msw/src/getters/object.ts new file mode 100644 index 000000000..b6fb2ce72 --- /dev/null +++ b/packages/msw/src/getters/object.ts @@ -0,0 +1,144 @@ +import { + ContextSpecs, + count, + GeneratorImport, + getKey, + isBoolean, + isReference, + MockOptions, +} from '@orval/core'; +import cuid from 'cuid'; +import { ReferenceObject, SchemaObject } from 'openapi3-ts'; +import { resolveMockValue } from '../resolvers/value'; +import { MockDefinition, MockSchemaObject } from '../types'; +import { combineSchemasMock } from './combine'; + +export const getMockObject = ({ + item, + mockOptions, + operationId, + tags, + combine, + context, + imports, +}: { + item: MockSchemaObject; + operationId: string; + mockOptions?: MockOptions; + tags: string[]; + combine?: { + separator: 'allOf' | 'oneOf' | 'anyOf'; + includedProperties: string[]; + }; + context: ContextSpecs; + imports: GeneratorImport[]; +}): MockDefinition => { + if (isReference(item)) { + return resolveMockValue({ + schema: { + ...item, + name: item.name, + path: item.path ? `${item.path}.${item.name}` : item.name, + }, + mockOptions, + operationId, + tags, + context, + imports, + }); + } + + if (item.allOf || item.oneOf || item.anyOf) { + const separator = item.allOf ? 'allOf' : item.oneOf ? 'oneOf' : 'anyOf'; + return combineSchemasMock({ + item, + separator, + mockOptions, + operationId, + tags, + combine, + context, + imports, + }); + } + + if (item.properties) { + let value = !combine || combine?.separator === 'oneOf' ? '{' : ''; + let imports: GeneratorImport[] = []; + let includedProperties: string[] = []; + value += Object.entries(item.properties) + .map(([key, prop]: [string, ReferenceObject | SchemaObject]) => { + if (combine?.includedProperties.includes(key)) { + return undefined; + } + + const isRequired = + mockOptions?.required || + (Array.isArray(item.required) ? item.required : []).includes(key); + + if (count(item.path, `\\.${key}\\.`) >= 1) { + return undefined; + } + + const resolvedValue = resolveMockValue({ + schema: { + ...prop, + name: key, + path: item.path ? `${item.path}.${key}` : `#.${key}`, + }, + mockOptions, + operationId, + tags, + context, + imports, + }); + + imports.push(...resolvedValue.imports); + includedProperties.push(key); + + const keyDefinition = getKey(key); + if (!isRequired && !resolvedValue.overrided) { + return `${keyDefinition}: faker.helpers.arrayElement([${resolvedValue.value}, undefined])`; + } + + return `${keyDefinition}: ${resolvedValue.value}`; + }) + .filter(Boolean) + .join(', '); + value += !combine || combine?.separator === 'oneOf' ? '}' : ''; + return { + value, + imports, + name: item.name, + includedProperties, + }; + } + + if (item.additionalProperties) { + if (isBoolean(item.additionalProperties)) { + return { value: `{}`, imports: [], name: item.name }; + } + + const resolvedValue = resolveMockValue({ + schema: { + ...item.additionalProperties, + name: item.name, + path: item.path ? `${item.path}.#` : '#', + }, + mockOptions, + operationId, + tags, + context, + imports, + }); + + return { + ...resolvedValue, + value: `{ + '${cuid()}': ${resolvedValue.value} + }`, + }; + } + + return { value: '{}', imports: [], name: item.name }; +}; diff --git a/packages/msw/src/getters/route.test.ts b/packages/msw/src/getters/route.test.ts new file mode 100644 index 000000000..1087d37ec --- /dev/null +++ b/packages/msw/src/getters/route.test.ts @@ -0,0 +1,21 @@ +import { getRouteMSW } from './route'; + +describe('getRoute getter', () => { + [ + ['/', '*/'], + ['/api/test/{id}', '*/api/test/:id'], + ['/api/test/{path*}', '*/api/test/:path'], + ['/api/test/{user_id}', '*/api/test/:userId'], + ['/api/test/{locale}.js', '*/api/test/:locale.js'], + ['/api/test/i18n-{locale}.js', '*/api/test/i18n-:locale.js'], + ['/api/test/{param1}-{param2}.js', '*/api/test/:param1-:param2.js'], + [ + '/api/test/user{param1}-{param2}.html', + '*/api/test/user:param1-:param2.html', + ], + ].forEach(([input, expected]) => { + it(`should process ${input} to ${expected}`, () => { + expect(getRouteMSW(input)).toBe(expected); + }); + }); +}); diff --git a/packages/msw/src/getters/route.ts b/packages/msw/src/getters/route.ts new file mode 100644 index 000000000..39d068fc3 --- /dev/null +++ b/packages/msw/src/getters/route.ts @@ -0,0 +1,39 @@ +import { camel, sanitize } from '@orval/core'; + +const hasParam = (path: string): boolean => /[^{]*{[\w*_-]*}.*/.test(path); + +const getRoutePath = (path: string): string => { + const matches = path.match(/([^{]*){?([\w*_-]*)}?(.*)/); + if (!matches?.length) return path; // impossible due to regexp grouping here, but for TS + + const prev = matches[1]; + const param = sanitize(camel(matches[2]), { + es5keyword: true, + underscore: true, + dash: true, + dot: true, + }); + const next = hasParam(matches[3]) ? getRoutePath(matches[3]) : matches[3]; + + if (hasParam(path)) { + return `${prev}:${param}${next}`; + } else { + return `${prev}${param}${next}`; + } +}; + +export const getRouteMSW = (route: string, baseUrl = '*') => { + const splittedRoute = route.split('/'); + + return splittedRoute.reduce((acc, path, i) => { + if (!path && !i) { + return acc; + } + + if (!path.includes('{')) { + return `${acc}/${path}`; + } + + return `${acc}/${getRoutePath(path)}`; + }, baseUrl); +}; diff --git a/packages/msw/src/getters/scalar.ts b/packages/msw/src/getters/scalar.ts new file mode 100644 index 000000000..38e8cdc28 --- /dev/null +++ b/packages/msw/src/getters/scalar.ts @@ -0,0 +1,215 @@ +import { + ContextSpecs, + escape, + GeneratorImport, + isReference, + isRootKey, + mergeDeep, + MockOptions, +} from '@orval/core'; +import { DEFAULT_FORMAT_MOCK } from '../constants'; +import { + getNullable, + resolveMockOverride, + resolveMockValue, +} from '../resolvers'; +import { MockDefinition, MockSchemaObject } from '../types'; +import { getMockObject } from './object'; + +export const getMockScalar = ({ + item, + imports, + mockOptions, + operationId, + tags, + combine, + context, +}: { + item: MockSchemaObject; + imports: GeneratorImport[]; + mockOptions?: MockOptions; + operationId: string; + isRef?: boolean; + tags: string[]; + combine?: { + separator: 'allOf' | 'oneOf' | 'anyOf'; + includedProperties: string[]; + }; + context: ContextSpecs; +}): MockDefinition => { + const operationProperty = resolveMockOverride( + mockOptions?.operations?.[operationId]?.properties, + item, + ); + + if (operationProperty) { + return operationProperty; + } + + const overrideTag = Object.entries(mockOptions?.tags ?? {}).reduce<{ + properties: Record; + }>( + (acc, [tag, options]) => + tags.includes(tag) ? mergeDeep(acc, options) : acc, + {} as { properties: Record }, + ); + + const tagProperty = resolveMockOverride(overrideTag?.properties, item); + + if (tagProperty) { + return tagProperty; + } + + const property = resolveMockOverride(mockOptions?.properties, item); + + if (property) { + return property; + } + + const ALL_FORMAT: Record = { + ...DEFAULT_FORMAT_MOCK, + ...(mockOptions?.format ?? {}), + }; + + if (item.format && ALL_FORMAT[item.format]) { + return { + value: getNullable(ALL_FORMAT[item.format], item.nullable), + imports: [], + name: item.name, + overrided: false, + }; + } + + switch (item.type) { + case 'number': + case 'integer': { + return { + value: getNullable( + `faker.datatype.number({min: ${item.minimum}, max: ${item.maximum}})`, + item.nullable, + ), + imports: [], + name: item.name, + }; + } + + case 'boolean': { + return { + value: 'faker.datatype.boolean()', + imports: [], + name: item.name, + }; + } + + case 'array': { + if (!item.items) { + return { value: '[]', imports: [], name: item.name }; + } + + const { + value, + enums, + imports: resolvedImports, + name, + } = resolveMockValue({ + schema: { + ...item.items, + name: item.name, + path: item.path ? `${item.path}.[]` : '#.[]', + }, + combine, + mockOptions, + operationId, + tags, + context, + imports, + }); + + if (enums) { + if (!isReference(item.items)) { + return { + value, + imports: resolvedImports, + name: item.name, + }; + } + + const enumImp = imports.find( + (imp) => name.replace('[]', '') === imp.name, + ); + const enumValue = enumImp?.name || name; + return { + value: `faker.helpers.arrayElements(Object.values(${enumValue}))`, + imports: enumImp + ? [ + ...resolvedImports, + { + ...enumImp, + values: true, + ...(!isRootKey(context.specKey, context.target) + ? { specKey: context.specKey } + : {}), + }, + ] + : resolvedImports, + name: item.name, + }; + } + + return { + value: + `Array.from({ length: faker.datatype.number({ ` + + `min: ${mockOptions?.arrayMin}, ` + + `max: ${mockOptions?.arrayMax} }) ` + + `}, (_, i) => i + 1).map(() => (${value}))`, + imports: resolvedImports, + name: item.name, + }; + } + + case 'string': { + let value = 'faker.random.word()'; + let imports: GeneratorImport[] = []; + + if (item.enum) { + let enumValue = + "['" + item.enum.map((e) => escape(e)).join("','") + "']"; + + if (item.isRef) { + enumValue = `Object.values(${item.name})`; + imports = [ + { + name: item.name, + values: true, + ...(!isRootKey(context.specKey, context.target) + ? { specKey: context.specKey } + : {}), + }, + ]; + } + + value = `faker.helpers.arrayElement(${enumValue})`; + } + + return { + value: getNullable(value, item.nullable), + enums: item.enum, + name: item.name, + imports, + }; + } + + case 'object': + default: { + return getMockObject({ + item, + mockOptions, + operationId, + tags, + combine, + context, + imports, + }); + } + } +}; diff --git a/packages/msw/src/index.ts b/packages/msw/src/index.ts new file mode 100644 index 000000000..81e54e72f --- /dev/null +++ b/packages/msw/src/index.ts @@ -0,0 +1,87 @@ +import { + generateDependencyImports, + GenerateMockImports, + GeneratorDependency, + GeneratorOptions, + GeneratorVerbOptions, + pascal, +} from '@orval/core'; +import { getRouteMSW } from './getters'; +import { getMockDefinition, getMockOptionsDataOverride } from './mocks'; + +const MSW_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [{ name: 'rest', values: true }], + dependency: 'msw', + }, + { + exports: [{ name: 'faker', values: true }], + dependency: '@faker-js/faker', + }, +]; + +export const generateMSWImports: GenerateMockImports = ({ + implementation, + imports, + specsName, + hasSchemaDir, + isAllowSyntheticDefaultImports, +}) => { + return generateDependencyImports( + implementation, + [...MSW_DEPENDENCIES, ...imports], + specsName, + hasSchemaDir, + isAllowSyntheticDefaultImports, + ); +}; + +export const generateMSW = ( + { operationId, response, verb, tags }: GeneratorVerbOptions, + { pathRoute, override, context }: GeneratorOptions, +) => { + const { definitions, definition, imports } = getMockDefinition({ + operationId, + tags, + response, + override, + context, + }); + + const route = getRouteMSW(pathRoute, override?.mock?.baseUrl); + const mockData = getMockOptionsDataOverride(operationId, override); + + let value = ''; + + if (mockData) { + value = mockData; + } else if (definitions.length > 1) { + value = `faker.helpers.arrayElement(${definition})`; + } else if (definitions[0]) { + value = definitions[0]; + } + + const responseType = response.contentTypes.includes('text/plain') + ? 'text' + : 'json'; + + return { + implementation: { + function: + value && value !== 'undefined' + ? `export const get${pascal(operationId)}Mock = () => (${value})\n\n` + : '', + handler: `rest.${verb}('${route}', (_req, res, ctx) => { + return res( + ctx.delay(${override?.mock?.delay ?? 1000}), + ctx.status(200, 'Mocked status'),${ + value && value !== 'undefined' + ? `\nctx.${responseType}(get${pascal(operationId)}Mock()),` + : '' + } + ) + }),`, + }, + imports, + }; +}; diff --git a/packages/msw/src/mocks.ts b/packages/msw/src/mocks.ts new file mode 100644 index 000000000..9a519b4eb --- /dev/null +++ b/packages/msw/src/mocks.ts @@ -0,0 +1,230 @@ +import { + ContextSpecs, + generalJSTypesWithArray, + GeneratorImport, + GetterResponse, + isFunction, + MockOptions, + NormalizedOverrideOutput, + resolveRef, + stringify, +} from '@orval/core'; +import { OpenAPIObject, SchemaObject } from 'openapi3-ts'; +import { getMockScalar } from './getters'; + +const getMockPropertiesWithoutFunc = (properties: any, spec: OpenAPIObject) => + Object.entries(isFunction(properties) ? properties(spec) : properties).reduce< + Record + >((acc, [key, value]) => { + const implementation = isFunction(value) + ? `(${value})()` + : stringify(value as string)!; + + acc[key] = implementation?.replace( + /import_faker.defaults|import_faker.faker/g, + 'faker', + ); + return acc; + }, {}); + +const getMockWithoutFunc = ( + spec: OpenAPIObject, + override?: NormalizedOverrideOutput, +): MockOptions => ({ + arrayMin: override?.mock?.arrayMin, + arrayMax: override?.mock?.arrayMax, + required: override?.mock?.required, + ...(override?.mock?.properties + ? { + properties: getMockPropertiesWithoutFunc( + override.mock.properties, + spec, + ), + } + : {}), + ...(override?.mock?.format + ? { + format: getMockPropertiesWithoutFunc(override.mock.format, spec), + } + : {}), + ...(override?.operations + ? { + operations: Object.entries(override.operations).reduce< + Exclude + >((acc, [key, value]) => { + if (value.mock?.properties) { + acc[key] = { + properties: getMockPropertiesWithoutFunc( + value.mock.properties, + spec, + ), + }; + } + + return acc; + }, {}), + } + : {}), + ...(override?.tags + ? { + tags: Object.entries(override.tags).reduce< + Exclude + >((acc, [key, value]) => { + if (value.mock?.properties) { + acc[key] = { + properties: getMockPropertiesWithoutFunc( + value.mock.properties, + spec, + ), + }; + } + + return acc; + }, {}), + } + : {}), +}); + +const getMockScalarJsTypes = ( + definition: string, + mockOptionsWithoutFunc: { [key: string]: unknown }, +) => { + const isArray = definition.endsWith('[]'); + const type = isArray ? definition.slice(0, -2) : definition; + + switch (type) { + case 'number': + return isArray + ? `Array.from({length: faker.datatype.number({` + + `min: ${mockOptionsWithoutFunc.arrayMin}, ` + + `max: ${mockOptionsWithoutFunc.arrayMax}}` + + `)}, () => faker.datatype.number())` + : 'faker.datatype.number().toString()'; + case 'string': + return isArray + ? `Array.from({length: faker.datatype.number({` + + `min: ${mockOptionsWithoutFunc?.arrayMin},` + + `max: ${mockOptionsWithoutFunc?.arrayMax}}` + + `)}, () => faker.random.word())` + : 'faker.random.word()'; + default: + return 'undefined'; + } +}; + +export const getResponsesMockDefinition = ({ + operationId, + tags, + response, + mockOptionsWithoutFunc, + transformer, + context, +}: { + operationId: string; + tags: string[]; + response: GetterResponse; + mockOptionsWithoutFunc: { [key: string]: unknown }; + transformer?: (value: unknown, definition: string) => string; + context: ContextSpecs; +}) => { + return response.types.success.reduce( + (acc, { value: definition, originalSchema, imports, isRef }) => { + if (!definition || generalJSTypesWithArray.includes(definition)) { + const value = getMockScalarJsTypes(definition, mockOptionsWithoutFunc); + + acc.definitions.push( + transformer ? transformer(value, response.definition.success) : value, + ); + + return acc; + } + + if (!originalSchema) { + return acc; + } + + const resolvedRef = resolveRef(originalSchema, context); + + const scalar = getMockScalar({ + item: { + name: definition, + ...resolvedRef.schema, + }, + imports, + mockOptions: mockOptionsWithoutFunc, + operationId, + tags, + context: isRef + ? { + ...context, + specKey: response.imports[0]?.specKey ?? context.specKey, + } + : context, + }); + + acc.imports.push(...scalar.imports); + acc.definitions.push( + transformer + ? transformer(scalar.value, response.definition.success) + : scalar.value.toString(), + ); + + return acc; + }, + { + definitions: [] as string[], + imports: [] as GeneratorImport[], + }, + ); +}; + +export const getMockDefinition = ({ + operationId, + tags, + response, + override, + transformer, + context, +}: { + operationId: string; + tags: string[]; + response: GetterResponse; + override: NormalizedOverrideOutput; + transformer?: (value: unknown, definition: string) => string; + context: ContextSpecs; +}) => { + const mockOptionsWithoutFunc = getMockWithoutFunc( + context.specs[context.specKey], + override, + ); + + const { definitions, imports } = getResponsesMockDefinition({ + operationId, + tags, + response, + mockOptionsWithoutFunc, + transformer, + context, + }); + + return { + definition: '[' + definitions.join(', ') + ']', + definitions, + imports, + }; +}; + +export const getMockOptionsDataOverride = ( + operationId: string, + override: NormalizedOverrideOutput, +) => { + const responseOverride = override?.operations?.[operationId]?.mock?.data; + const implementation = isFunction(responseOverride) + ? `(${responseOverride})()` + : stringify(responseOverride); + + return implementation?.replace( + /import_faker.defaults|import_faker.faker/g, + 'faker', + ); +}; diff --git a/packages/msw/src/resolvers/index.ts b/packages/msw/src/resolvers/index.ts new file mode 100644 index 000000000..937b73fd1 --- /dev/null +++ b/packages/msw/src/resolvers/index.ts @@ -0,0 +1 @@ +export * from './value'; diff --git a/packages/msw/src/resolvers/value.ts b/packages/msw/src/resolvers/value.ts new file mode 100644 index 000000000..21e0c022d --- /dev/null +++ b/packages/msw/src/resolvers/value.ts @@ -0,0 +1,118 @@ +import { + ContextSpecs, + GeneratorImport, + getRefInfo, + isReference, + MockOptions, +} from '@orval/core'; +import get from 'lodash.get'; +import { SchemaObject } from 'openapi3-ts'; +import { getMockScalar } from '../getters/scalar'; +import { MockDefinition, MockSchemaObject } from '../types'; + +const isRegex = (key: string) => key[0] === '/' && key[key.length - 1] === '/'; + +export const resolveMockOverride = ( + properties: Record | undefined = {}, + item: SchemaObject & { name: string; path?: string }, +) => { + const property = Object.entries(properties).find(([key]) => { + if (isRegex(key)) { + const regex = new RegExp(key.slice(1, key.length - 1)); + if (regex.test(item.name)) { + return true; + } + } + + if (`#.${key}` === (item.path ? item.path : `#.${item.name}`)) { + return true; + } + + return false; + }); + + if (!property) { + return; + } + + return { + value: getNullable(property[1] as string, item.nullable), + imports: [], + name: item.name, + overrided: true, + }; +}; + +export const getNullable = (value: string, nullable?: boolean) => + nullable ? `faker.helpers.arrayElement([${value}, null])` : value; + +export const resolveMockValue = ({ + schema, + mockOptions, + operationId, + tags, + combine, + context, + imports, +}: { + schema: MockSchemaObject; + operationId: string; + mockOptions?: MockOptions; + tags: string[]; + combine?: { + separator: 'allOf' | 'oneOf' | 'anyOf'; + includedProperties: string[]; + }; + context: ContextSpecs; + imports: GeneratorImport[]; +}): MockDefinition & { type?: string } => { + if (isReference(schema)) { + const { + name, + specKey = context.specKey, + refPaths, + } = getRefInfo(schema.$ref, context); + + const schemaRef = get(context.specs[specKey], refPaths); + + const newSchema = { + ...schemaRef, + name, + path: schema.path, + isRef: true, + }; + + const scalar = getMockScalar({ + item: newSchema, + mockOptions, + operationId, + tags, + combine, + context: { + ...context, + specKey, + }, + imports, + }); + + return { + ...scalar, + type: newSchema.type, + }; + } + + const scalar = getMockScalar({ + item: schema, + mockOptions, + operationId, + tags, + combine, + context, + imports, + }); + + return { + ...scalar, + type: schema.type, + }; +}; diff --git a/packages/msw/src/types.ts b/packages/msw/src/types.ts new file mode 100644 index 000000000..58db4d31b --- /dev/null +++ b/packages/msw/src/types.ts @@ -0,0 +1,17 @@ +import { GeneratorImport } from '@orval/core'; +import { SchemaObject } from 'openapi3-ts'; + +export interface MockDefinition { + value: string; + enums?: string[]; + imports: GeneratorImport[]; + name: string; + overrided?: boolean; + includedProperties?: string[]; +} + +export type MockSchemaObject = SchemaObject & { + name: string; + path?: string; + isRef?: boolean; +}; diff --git a/packages/msw/tsconfig.json b/packages/msw/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/msw/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/orval/README.md b/packages/orval/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/orval/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/orval/package.json b/packages/orval/package.json new file mode 100644 index 000000000..fa4252f68 --- /dev/null +++ b/packages/orval/package.json @@ -0,0 +1,77 @@ +{ + "name": "orval", + "description": "A swagger client generator for typescript", + "version": "6.11.0-alpha.1", + "license": "MIT", + "files": [ + "dist" + ], + "bin": { + "orval": "dist/bin/orval.js" + }, + "type": "commonjs", + "main": "dist/index.js", + "keywords": [ + "rest", + "client", + "swagger", + "open-api", + "fetch", + "data fetching", + "code-generation", + "angular", + "react", + "react-query", + "svelte", + "svelte-query", + "vue", + "vue-query", + "msw", + "mock", + "axios", + "vue-query", + "vue", + "swr" + ], + "author": { + "name": "Victor Bury", + "email": "victor@anymaniax.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/anymaniax/orval" + }, + "scripts": { + "build": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/bin/orval.ts ./src/index.ts --target node12 --clean --watch src --onSuccess 'yarn generate-api'", + "lint": "eslint src/**/*.ts", + "generate-api": "node ./dist/bin/orval.js --config ../../samples/react-query/basic/orval.config.ts --watch" + }, + "devDependencies": { + "@types/chalk": "^2.2.0", + "@types/inquirer": "^8.2.2", + "@types/lodash.uniq": "^4.5.7" + }, + "dependencies": { + "@orval/core": "6.11.0-alpha.1", + "@orval/query": "6.11.0-alpha.1", + "@orval/angular": "6.11.0-alpha.1", + "@orval/axios": "6.11.0-alpha.1", + "@orval/swr": "6.11.0-alpha.1", + "@orval/msw": "6.11.0-alpha.1", + "@apidevtools/swagger-parser": "^10.1.0", + "openapi3-ts": "^3.0.0", + "chalk": "^4.1.2", + "upath": "^2.0.1", + "lodash.uniq": "^4.5.0", + "execa": "^5.1.1", + "string-argv": "^0.3.1", + "fs-extra": "^10.1.0", + "inquirer": "^8.2.4", + "find-up": "5.0.0", + "tsconfck": "^2.0.1", + "chokidar": "^3.5.3", + "ajv": "^8.11.0", + "cac": "^6.7.12" + } +} diff --git a/packages/orval/src/api.ts b/packages/orval/src/api.ts new file mode 100644 index 000000000..7c47569f0 --- /dev/null +++ b/packages/orval/src/api.ts @@ -0,0 +1,112 @@ +import { + asyncReduce, + ContextSpecs, + generateVerbsOptions, + GeneratorApiBuilder, + GeneratorApiOperations, + GeneratorSchema, + getRoute, + isReference, + NormalizedOutputOptions, + resolveRef, +} from '@orval/core'; +import { generateMSWImports } from '@orval/msw'; +import { PathItemObject } from 'openapi3-ts'; +import { + generateClientFooter, + generateClientHeader, + generateClientImports, + generateClientTitle, + generateOperations, +} from './client'; + +export const getApiBuilder = async ({ + output, + context, +}: { + output: NormalizedOutputOptions; + context: ContextSpecs; +}): Promise => { + const api = await asyncReduce( + Object.entries(context.specs[context.specKey].paths), + async (acc, [pathRoute, verbs]: [string, PathItemObject]) => { + const route = getRoute(pathRoute); + + let resolvedVerbs = verbs; + let resolvedContext = context; + + if (isReference(verbs)) { + const { schema, imports } = resolveRef(verbs, context); + + resolvedVerbs = schema; + + resolvedContext = { + ...context, + ...(imports.length + ? { + specKey: imports[0].specKey, + } + : {}), + }; + } + + let verbsOptions = await generateVerbsOptions({ + verbs: resolvedVerbs, + output, + route, + context: resolvedContext, + }); + + // GitHub #564 check if we want to exclude deprecated operations + if (output.override.useDeprecatedOperations === false) { + verbsOptions = verbsOptions.filter((verb) => { + return !verb.deprecated; + }); + } + + const schemas = verbsOptions.reduce( + (acc, { queryParams, headers, body, response }) => { + if (queryParams) { + acc.push(queryParams.schema, ...queryParams.deps); + } + if (headers) { + acc.push(headers.schema, ...headers.deps); + } + + acc.push(...body.schemas); + acc.push(...response.schemas); + + return acc; + }, + [] as GeneratorSchema[], + ); + + const pathOperations = generateOperations(output.client, verbsOptions, { + route, + pathRoute, + override: output.override, + context: resolvedContext, + mock: !!output.mock, + }); + + acc.schemas.push(...schemas); + acc.operations = { ...acc.operations, ...pathOperations }; + + return acc; + }, + { + operations: {}, + schemas: [], + } as GeneratorApiOperations, + ); + + return { + operations: api.operations, + schemas: api.schemas, + title: generateClientTitle, + header: generateClientHeader, + footer: generateClientFooter, + imports: generateClientImports, + importsMock: generateMSWImports, + }; +}; diff --git a/packages/orval/src/bin/orval.ts b/packages/orval/src/bin/orval.ts new file mode 100644 index 000000000..04a203898 --- /dev/null +++ b/packages/orval/src/bin/orval.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { isString, log, startMessage } from '@orval/core'; +import { cac } from 'cac'; +import chalk from 'chalk'; +import { generateConfig, generateSpec } from '../generate'; +import pkg from '../../package.json'; +import { normalizeOptions } from '../utils/options'; +import { startWatcher } from '../utils/watcher'; + +const cli = cac('orval'); + +startMessage({ + name: pkg.name, + version: pkg.version, + description: pkg.description, +}); + +cli.version(pkg.version); + +cli + .command( + '[config]', + 'generate client with appropriate type-signatures from OpenAPI specs', + { + ignoreOptionDefaultValue: true, + }, + ) + .option('-o, --output ', 'output file destination') + .option('-i, --input ', 'input file (yaml or json openapi specs)') + .option('-c, --config ', 'override flags by a config file') + .option('-p, --project ', 'focus a project of the config') + .option('-m, --mode ', 'default mode that will be used') + .option('-c, --client ', 'default client that will be used') + .option('--mock', 'activate the mock') + .option( + '-w, --watch [path]', + 'Watch mode, if path is not specified, it watches the input target', + ) + .option('--clean [path]', 'Clean output directory') + .option('--prettier [path]', 'Prettier generated files') + .option('--tslint [path]', 'tslint generated files') + .option('--tsconfig [path]', 'path to your tsconfig file') + .action(async (paths, cmd) => { + if (!cmd.config && isString(cmd.input) && isString(cmd.output)) { + const normalizedOptions = await normalizeOptions({ + input: cmd.input, + output: { + target: cmd.output, + clean: cmd.clean, + prettier: cmd.prettier, + tslint: cmd.tslint, + mock: cmd.mock, + client: cmd.client, + mode: cmd.mode, + tsconfig: cmd.tsconfig, + }, + }); + + if (cmd.watch) { + startWatcher( + cmd.watch, + async () => { + try { + await generateSpec(process.cwd(), normalizedOptions); + } catch (e) { + log(chalk.red(`🛑 ${e}`)); + } + }, + normalizedOptions.input.target as string, + ); + } else { + try { + await generateSpec(process.cwd(), normalizedOptions); + } catch (e) { + log(chalk.red(`🛑 ${e}`)); + } + } + } else { + await generateConfig(cmd.config, { + projectName: cmd.project, + watch: cmd.watch, + clean: cmd.clean, + prettier: cmd.prettier, + tslint: cmd.tslint, + mock: cmd.mock, + client: cmd.client, + mode: cmd.mode, + tsconfig: cmd.tsconfig, + input: cmd.input, + output: cmd.output, + }); + } + }); + +cli.help(); + +cli.parse(process.argv); diff --git a/packages/orval/src/client.ts b/packages/orval/src/client.ts new file mode 100644 index 000000000..1d43968ed --- /dev/null +++ b/packages/orval/src/client.ts @@ -0,0 +1,196 @@ +import angular from '@orval/angular'; +import axios from '@orval/axios'; +import { + generateDependencyImports, + GeneratorClientFooter, + GeneratorClientHeader, + GeneratorClientImports, + GeneratorClients, + GeneratorClientTitle, + GeneratorOperations, + GeneratorOptions, + GeneratorVerbOptions, + GeneratorVerbsOptions, + isFunction, + OutputClient, + OutputClientFunc, + pascal, +} from '@orval/core'; +import { generateMSW } from '@orval/msw'; +import query from '@orval/query'; +import swr from '@orval/swr'; + +const DEFAULT_CLIENT = OutputClient.AXIOS; + +export const GENERATOR_CLIENT: GeneratorClients = { + axios: axios({ type: 'axios' }), + 'axios-functions': axios({ type: 'axios-functions' }), + angular: angular(), + 'react-query': query({ type: 'react-query' }), + 'svelte-query': query({ type: 'svelte-query' }), + 'vue-query': query({ type: 'vue-query' }), + swr: swr(), +}; + +const getGeneratorClient = (outputClient: OutputClient | OutputClientFunc) => { + const generator = isFunction(outputClient) + ? outputClient(GENERATOR_CLIENT) + : GENERATOR_CLIENT[outputClient]; + + if (!generator) { + throw `Oups... 🍻. Client not found: ${outputClient}`; + } + + return generator; +}; + +export const generateClientImports: GeneratorClientImports = ({ + client = DEFAULT_CLIENT, + implementation, + imports, + specsName, + hasSchemaDir, + isAllowSyntheticDefaultImports, + hasGlobalMutator, + packageJson, +}) => { + const { dependencies } = getGeneratorClient(client); + return generateDependencyImports( + implementation, + [...dependencies(hasGlobalMutator, packageJson), ...imports], + specsName, + hasSchemaDir, + isAllowSyntheticDefaultImports, + ); +}; + +export const generateClientHeader: GeneratorClientHeader = ({ + outputClient = DEFAULT_CLIENT, + isRequestOptions, + isGlobalMutator, + isMutator, + provideIn, + hasAwaitedType, + titles, +}) => { + const { header } = getGeneratorClient(outputClient); + return { + implementation: header({ + title: titles.implementation, + isRequestOptions, + isGlobalMutator, + isMutator, + provideIn, + hasAwaitedType, + }), + implementationMSW: `export const ${titles.implementationMSW} = () => [\n`, + }; +}; + +export const generateClientFooter: GeneratorClientFooter = ({ + outputClient = DEFAULT_CLIENT, + operationNames, + hasMutator, + hasAwaitedType, + titles, +}) => { + const { footer } = getGeneratorClient(outputClient); + let implementation: string; + try { + if (isFunction(outputClient)) { + implementation = (footer as (operationNames: any) => string)( + operationNames, + ); + // being here means that the previous call worked + console.warn( + '[WARN] Passing an array of strings for operations names to the footer function is deprecated and will be removed in a future major release. Please pass them in an object instead: { operationNames: string[] }.', + ); + } else { + implementation = footer({ + operationNames, + title: titles.implementation, + hasMutator, + hasAwaitedType, + }); + } + } catch (e) { + implementation = footer({ + operationNames, + title: titles.implementation, + hasMutator, + hasAwaitedType, + }); + } + + return { + implementation, + implementationMSW: `]\n`, + }; +}; + +export const generateClientTitle: GeneratorClientTitle = ({ + outputClient = DEFAULT_CLIENT, + title, + customTitleFunc, +}) => { + const { title: generatorTitle } = getGeneratorClient(outputClient); + if (customTitleFunc) { + const customTitle = customTitleFunc(title); + return { + implementation: generatorTitle(customTitle), + implementationMSW: `get${pascal(customTitle)}MSW`, + }; + } + return { + implementation: generatorTitle(title), + implementationMSW: `get${pascal(title)}MSW`, + }; +}; + +const generateMock = ( + verbOption: GeneratorVerbOptions, + options: GeneratorOptions, +) => { + if (!options.mock) { + return { + implementation: { + function: '', + handler: '', + }, + imports: [], + }; + } + + if (isFunction(options.mock)) { + return options.mock(verbOption, options); + } + + return generateMSW(verbOption, options); +}; + +export const generateOperations = ( + outputClient: OutputClient | OutputClientFunc = DEFAULT_CLIENT, + verbsOptions: GeneratorVerbsOptions, + options: GeneratorOptions, +): GeneratorOperations => { + return verbsOptions.reduce((acc, verbOption) => { + const { client: generatorClient } = getGeneratorClient(outputClient); + const client = generatorClient(verbOption, options, outputClient); + const msw = generateMock(verbOption, options); + + acc[verbOption.operationId] = { + implementation: verbOption.doc + client.implementation, + imports: client.imports, + implementationMSW: msw.implementation, + importsMSW: msw.imports, + tags: verbOption.tags, + mutator: verbOption.mutator, + formData: verbOption.formData, + formUrlEncoded: verbOption.formUrlEncoded, + operationName: verbOption.operationName, + types: client.types, + }; + + return acc; + }, {} as GeneratorOperations); +}; diff --git a/packages/orval/src/generate.ts b/packages/orval/src/generate.ts new file mode 100644 index 000000000..92c96b31f --- /dev/null +++ b/packages/orval/src/generate.ts @@ -0,0 +1,136 @@ +import { + asyncReduce, + ConfigExternal, + errorMessage, + getFileInfo, + GlobalOptions, + isFunction, + isString, + loadFile, + log, + NormalizedOptions, + NormizaledConfig, + removeFiles, +} from '@orval/core'; +import chalk from 'chalk'; +import { dirname } from 'upath'; +import { importSpecs } from './import-specs'; +import { writeSpecs } from './write-specs'; + +import { normalizeOptions } from './utils/options'; +import { startWatcher } from './utils/watcher'; + +export const generateSpec = async ( + workspace: string, + options: NormalizedOptions, + projectName?: string, +) => { + if (options.output.clean) { + const extraPatterns = Array.isArray(options.output.clean) + ? options.output.clean + : []; + + if (options.output.target) { + await removeFiles( + ['**/*', '!**/*.d.ts', ...extraPatterns], + getFileInfo(options.output.target).dirname, + ); + } + if (options.output.schemas) { + await removeFiles( + ['**/*', '!**/*.d.ts', ...extraPatterns], + getFileInfo(options.output.schemas).dirname, + ); + } + log(`${projectName ? `${projectName}: ` : ''}Cleaning output folder`); + } + + const writeSpecBuilder = await importSpecs(workspace, options); + await writeSpecs(writeSpecBuilder, workspace, options, projectName); +}; + +export const generateSpecs = async ( + config: NormizaledConfig, + workspace: string, + projectName?: string, +) => { + if (projectName) { + const options = config[projectName]; + + if (options) { + try { + await generateSpec(workspace, options, projectName); + } catch (e) { + log(chalk.red(`🛑 ${projectName ? `${projectName} - ` : ''}${e}`)); + } + } else { + errorMessage('Project not found'); + process.exit(1); + } + return; + } + + return asyncReduce( + Object.entries(config), + async (acc, [projectName, options]) => { + try { + acc.push(await generateSpec(workspace, options, projectName)); + } catch (e) { + log(chalk.red(`🛑 ${projectName ? `${projectName} - ` : ''}${e}`)); + } + return acc; + }, + [] as void[], + ); +}; + +export const generateConfig = async ( + configFile?: string, + options?: GlobalOptions, +) => { + const { + path, + file: configExternal, + error, + } = await loadFile(configFile, { + defaultFileName: 'orval.config', + }); + + if (!configExternal) { + throw `failed to load from ${path} => ${error}`; + } + + const workspace = dirname(path); + + const config = await (isFunction(configExternal) + ? configExternal() + : configExternal); + + const normalizedConfig = await asyncReduce( + Object.entries(config), + async (acc, [key, value]) => { + acc[key] = await normalizeOptions(value, workspace, options); + + return acc; + }, + {} as NormizaledConfig, + ); + + const fileToWatch = Object.entries(normalizedConfig) + .filter( + ([project]) => + options?.projectName === undefined || project === options?.projectName, + ) + .map(([, { input }]) => input.target) + .filter((target) => isString(target)) as string[]; + + if (options?.watch && fileToWatch.length) { + startWatcher( + options?.watch, + () => generateSpecs(normalizedConfig, workspace, options?.projectName), + fileToWatch, + ); + } else { + await generateSpecs(normalizedConfig, workspace, options?.projectName); + } +}; diff --git a/packages/orval/src/import-open-api.ts b/packages/orval/src/import-open-api.ts new file mode 100644 index 000000000..237f3b5bb --- /dev/null +++ b/packages/orval/src/import-open-api.ts @@ -0,0 +1,193 @@ +import { + asyncReduce, + ContextSpecs, + dynamicImport, + generateComponentDefinition, + generateParameterDefinition, + generateSchemasDefinition, + GeneratorSchema, + ibmOpenapiValidator, + ImportOpenApi, + InputOptions, + isObject, + isReference, + NormalizedOutputOptions, + openApiConverter, + WriteSpecsBuilder, +} from '@orval/core'; +import omit from 'lodash.omit'; +import { OpenAPIObject, SchemasObject } from 'openapi3-ts'; +import { getApiBuilder } from './api'; + +export const importOpenApi = async ({ + data, + input, + output, + target, + workspace, +}: ImportOpenApi): Promise => { + const specs = await generateInputSpecs({ specs: data, input, workspace }); + + const schemas = getApiSchemas({ output, target, workspace, specs }); + + const api = await getApiBuilder({ + output, + context: { + specKey: target, + target, + workspace, + specs, + override: output.override, + tslint: output.tslint, + tsconfig: output.tsconfig, + packageJson: output.packageJson, + }, + }); + + return { + ...api, + schemas: { + ...schemas, + [target]: [...(schemas[target] ?? []), ...api.schemas], + }, + target, + info: specs[target].info, + }; +}; + +const generateInputSpecs = async ({ + specs, + input, + workspace, +}: { + specs: Record; + input: InputOptions; + workspace: string; +}): Promise> => { + const transformerFn = input.override?.transformer + ? await dynamicImport(input.override.transformer, workspace) + : undefined; + + return asyncReduce( + Object.entries(specs), + async (acc, [specKey, value]) => { + const schema = await openApiConverter( + value, + input.converterOptions, + specKey, + ); + + const transfomedSchema = transformerFn ? transformerFn(schema) : schema; + + if (input.validation) { + await ibmOpenapiValidator(transfomedSchema); + } + + acc[specKey] = transfomedSchema; + + return acc; + }, + {} as Record, + ); +}; + +const getApiSchemas = ({ + output, + target, + workspace, + specs, +}: { + output: NormalizedOutputOptions; + workspace: string; + target: string; + specs: Record; +}) => { + return Object.entries(specs).reduce((acc, [specKey, spec]) => { + const context: ContextSpecs = { + specKey, + target, + workspace, + specs, + override: output.override, + tslint: output.tslint, + tsconfig: output.tsconfig, + packageJson: output.packageJson, + }; + + const schemaDefinition = generateSchemasDefinition( + !spec.openapi + ? getAllSchemas(spec) + : (spec.components?.schemas as SchemasObject), + context, + output.override.components.schemas.suffix, + ); + + const responseDefinition = generateComponentDefinition( + spec.components?.responses, + context, + output.override.components.responses.suffix, + ); + + const bodyDefinition = generateComponentDefinition( + spec.components?.requestBodies, + context, + output.override.components.requestBodies.suffix, + ); + + const parameters = generateParameterDefinition( + spec.components?.parameters, + context, + output.override.components.parameters.suffix, + ); + + const schemas = [ + ...schemaDefinition, + ...responseDefinition, + ...bodyDefinition, + ...parameters, + ]; + + if (!schemas.length) { + return acc; + } + + acc[specKey] = schemas; + + return acc; + }, {} as Record); +}; + +const getAllSchemas = (spec: object): SchemasObject => { + const cleanedSpec = omit(spec, [ + 'openapi', + 'info', + 'servers', + 'paths', + 'components', + 'security', + 'tags', + 'externalDocs', + ]); + + const schemas = Object.entries(cleanedSpec).reduce( + (acc, [key, value]) => { + if (!isObject(value)) { + return acc; + } + + if (!value.type && !isReference(value)) { + return { ...acc, ...getAllSchemas(value) }; + } + + acc[key] = value; + + return acc; + }, + {}, + ); + + return { + ...schemas, + ...((spec as OpenAPIObject)?.components?.schemas as SchemasObject), + }; +}; diff --git a/packages/orval/src/import-specs.ts b/packages/orval/src/import-specs.ts new file mode 100644 index 000000000..0bd76989f --- /dev/null +++ b/packages/orval/src/import-specs.ts @@ -0,0 +1,69 @@ +import SwaggerParser from '@apidevtools/swagger-parser'; +import { + isObject, + isUrl, + log, + NormalizedOptions, + SwaggerParserOptions, + WriteSpecsBuilder, +} from '@orval/core'; +import chalk from 'chalk'; +import { resolve } from 'upath'; +import { importOpenApi } from './import-open-api'; + +const resolveSpecs = async ( + path: string, + { validate, ...options }: SwaggerParserOptions, + isUrl: boolean, +) => { + if (validate) { + try { + await SwaggerParser.validate(path); + } catch (e: any) { + if (e?.name === 'ParserError') { + throw e; + } + log(`⚠️ ${chalk.yellow(e)}`); + } + } + + const data = (await SwaggerParser.resolve(path, options)).values(); + + if (isUrl) { + return data; + } + + // normalizing slashes after SwaggerParser + return Object.fromEntries( + Object.entries(data).map(([key, value]) => [resolve(key), value]), + ); +}; + +export const importSpecs = async ( + workspace: string, + options: NormalizedOptions, +): Promise => { + const { input, output } = options; + + if (isObject(input.target)) { + return importOpenApi({ + data: { [workspace]: input.target }, + input, + output, + target: workspace, + workspace, + }); + } + + const isPathUrl = isUrl(input.target); + + const data = await resolveSpecs(input.target, input.parserOptions, isPathUrl); + + return importOpenApi({ + data, + input, + output, + target: input.target, + workspace, + }); +}; diff --git a/packages/orval/src/index.ts b/packages/orval/src/index.ts new file mode 100644 index 000000000..7241828b6 --- /dev/null +++ b/packages/orval/src/index.ts @@ -0,0 +1,63 @@ +import { + GlobalOptions, + isString, + log, + Options, + OptionsExport, +} from '@orval/core'; +import chalk from 'chalk'; +import { generateConfig, generateSpec } from './generate'; +import { defineConfig, normalizeOptions } from './utils/options'; +import { startWatcher } from './utils/watcher'; + +const generate = async ( + optionsExport?: string | OptionsExport, + workspace = process.cwd(), + options?: GlobalOptions, +) => { + if (!optionsExport || isString(optionsExport)) { + return generateConfig(optionsExport, options); + } + + const normalizedOptions = await normalizeOptions( + optionsExport, + workspace, + options, + ); + + if (options?.watch) { + startWatcher( + options?.watch, + async () => { + try { + await generateSpec(workspace, normalizedOptions); + } catch (e) { + log( + chalk.red( + `🛑 ${ + options?.projectName ? `${options?.projectName} - ` : '' + }${e}`, + ), + ); + } + }, + normalizedOptions.input.target as string, + ); + } else { + try { + return await generateSpec(workspace, normalizedOptions); + } catch (e) { + log( + chalk.red( + `🛑 ${options?.projectName ? `${options?.projectName} - ` : ''}${e}`, + ), + ); + } + } +}; + +export { defineConfig }; +export { Options }; +export { generate }; + +export default generate; diff --git a/packages/orval/src/utils/executeHook.ts b/packages/orval/src/utils/executeHook.ts new file mode 100644 index 000000000..44594fe38 --- /dev/null +++ b/packages/orval/src/utils/executeHook.ts @@ -0,0 +1,32 @@ +import { + Hook, + isFunction, + isString, + log, + NormalizedHookCommand, +} from '@orval/core'; +import chalk from 'chalk'; +import execa from 'execa'; +import { parseArgsStringToArgv } from 'string-argv'; + +export const executeHook = async ( + name: Hook, + commands: NormalizedHookCommand = [], + args: string[] = [], +) => { + log(chalk.white(`Running ${name} hook...`)); + + for (const command of commands) { + if (isString(command)) { + const [cmd, ..._args] = [...parseArgsStringToArgv(command), ...args]; + + try { + await execa(cmd, _args); + } catch (e) { + log(chalk.red(`🛑 Failed to run ${name} hook: ${e}`)); + } + } else if (isFunction(command)) { + await command(args); + } + } +}; diff --git a/packages/orval/src/utils/github.ts b/packages/orval/src/utils/github.ts new file mode 100644 index 000000000..417a8299c --- /dev/null +++ b/packages/orval/src/utils/github.ts @@ -0,0 +1,141 @@ +import SwaggerParser from '@apidevtools/swagger-parser'; +import { outputFile, pathExists, readFile, unlink } from 'fs-extra'; +import https from 'https'; +import inquirer from 'inquirer'; +import { join } from 'upath'; +import { request } from './request'; +export const getGithubSpecReq = ({ + accessToken, + repo, + owner, + branch, + path, +}: { + accessToken: string; + repo: string; + owner: string; + branch: string; + path: string; +}): [https.RequestOptions, string] => { + const payload = JSON.stringify({ + query: `query { + repository(name: "${repo}", owner: "${owner}") { + object(expression: "${branch}:${path}") { + ... on Blob { + text + } + } + } + }`, + }); + + return [ + { + method: 'POST', + hostname: 'api.github.com', + path: '/graphql', + headers: { + 'content-type': 'application/json', + 'user-agent': 'orval-importer', + authorization: `bearer ${accessToken}`, + 'Content-Length': payload.length, + }, + }, + payload, + ]; +}; + +export const getGithubAcessToken = async (githubTokenPath: string) => { + if (await pathExists(githubTokenPath)) { + return readFile(githubTokenPath, 'utf-8'); + } else { + const answers = await inquirer.prompt<{ + githubToken: string; + saveToken: boolean; + }>([ + { + type: 'input', + name: 'githubToken', + message: + 'Please provide a GitHub token with `repo` rules checked (https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/)', + }, + { + type: 'confirm', + name: 'saveToken', + message: + 'Would you like to store your token for the next time? (stored in your node_modules)', + }, + ]); + if (answers.saveToken) { + await outputFile(githubTokenPath, answers.githubToken); + } + return answers.githubToken; + } +}; + +export const getGithubOpenApi = async (url: string): Promise => { + const githubTokenPath = join(__dirname, '.githubToken'); + const accessToken = await getGithubAcessToken(githubTokenPath); + const [info] = url.split('github.com/').slice(-1); + + const [owner, repo, , branch, ...paths] = info.split('/'); + const path = paths.join('/'); + + try { + const { body } = await request<{ + data?: { repository: any }; + errors?: { type: string }[]; + }>(...getGithubSpecReq({ accessToken, repo, owner, branch, path })); + if (body.errors?.length) { + const isErrorRemoveLink = body.errors?.some( + (error) => error?.type === 'NOT_FOUND', + ); + + if (isErrorRemoveLink) { + const answers = await inquirer.prompt<{ removeToken: boolean }>([ + { + type: 'confirm', + name: 'removeToken', + message: + "Your token doesn't have the correct permissions, should we remove it?", + }, + ]); + if (answers.removeToken) { + await unlink(githubTokenPath); + } + } + } + + return body.data?.repository?.object.text; + } catch (e) { + if (!e.body) { + throw `Oups... 🍻. ${e}`; + } + + if (e.body.message === 'Bad credentials') { + const answers = await inquirer.prompt<{ removeToken: boolean }>([ + { + type: 'confirm', + name: 'removeToken', + message: + "Your token doesn't have the correct permissions, should we remove it?", + }, + ]); + if (answers.removeToken) { + await unlink(githubTokenPath); + } + } + throw e.body.message || `Oups... 🍻. ${e}`; + } +}; + +export const githubResolver = { + order: 199, + canRead(file: SwaggerParser.FileInfo) { + return file.url.includes('github.com'); + }, + + read(file: SwaggerParser.FileInfo) { + return getGithubOpenApi(file.url); + }, +}; diff --git a/packages/orval/src/utils/index.ts b/packages/orval/src/utils/index.ts new file mode 100644 index 000000000..cd8d73e13 --- /dev/null +++ b/packages/orval/src/utils/index.ts @@ -0,0 +1,7 @@ +export * from './executeHook'; +export * from './github'; +export * from './options'; +export * from './package-json'; +export * from './request'; +export * from './tsconfig'; +export * from './watcher'; diff --git a/packages/orval/src/utils/options.ts b/packages/orval/src/utils/options.ts new file mode 100644 index 000000000..727f6efd7 --- /dev/null +++ b/packages/orval/src/utils/options.ts @@ -0,0 +1,358 @@ +import { + ConfigExternal, + createLogger, + GlobalOptions, + Hook, + HookFunction, + HooksOptions, + isBoolean, + isFunction, + isObject, + isString, + isUrl, + mergeDeep, + Mutator, + NormalizedHookOptions, + NormalizedMutator, + NormalizedOperationOptions, + NormalizedOptions, + OperationOptions, + OptionsExport, + OutputClient, + OutputMode, + RefComponentSuffix, + SwaggerParserOptions, +} from '@orval/core'; +import chalk from 'chalk'; +import { InfoObject } from 'openapi3-ts'; +import { resolve } from 'upath'; +import pkg from '../../package.json'; +import { githubResolver } from './github'; +import { loadPackageJson } from './package-json'; +import { loadTsconfig } from './tsconfig'; + +/** + * Type helper to make it easier to use orval.config.ts + * accepts a direct {@link ConfigExternal} object. + */ +export function defineConfig(options: ConfigExternal): ConfigExternal { + return options; +} + +export const normalizeOptions = async ( + optionsExport: OptionsExport, + workspace = process.cwd(), + globalOptions: GlobalOptions = {}, +) => { + const options = await (isFunction(optionsExport) + ? optionsExport() + : optionsExport); + + if (!options.input) { + createLogger().error(chalk.red(`Config require an input`)); + process.exit(1); + } + + if (!options.output) { + createLogger().error(chalk.red(`Config require an output`)); + process.exit(1); + } + + const inputOptions = isString(options.input) + ? { target: options.input } + : options.input; + + const outputOptions = isString(options.output) + ? { target: options.output } + : options.output; + + const outputWorkspace = normalizePath( + outputOptions.workspace || '', + workspace, + ); + + const { clean, prettier, client, mode, mock, tslint } = globalOptions; + + const tsconfig = await loadTsconfig( + outputOptions.tsconfig || globalOptions.tsconfig, + workspace, + ); + + const packageJson = await loadPackageJson( + outputOptions.packageJson || globalOptions.packageJson, + workspace, + ); + + const normalizedOptions: NormalizedOptions = { + input: { + target: globalOptions.input + ? normalizePathOrUrl(globalOptions.input, process.cwd()) + : normalizePathOrUrl(inputOptions.target, workspace), + validation: inputOptions.validation || false, + override: { + transformer: normalizePath( + inputOptions.override?.transformer, + workspace, + ), + }, + converterOptions: inputOptions.converterOptions ?? {}, + parserOptions: mergeDeep( + parserDefaultOptions, + inputOptions.parserOptions ?? {}, + ), + }, + output: { + target: globalOptions.output + ? normalizePath(globalOptions.output, process.cwd()) + : normalizePath(outputOptions.target, outputWorkspace), + schemas: normalizePath(outputOptions.schemas, outputWorkspace), + workspace: outputOptions.workspace ? outputWorkspace : undefined, + client: outputOptions.client ?? client ?? OutputClient.AXIOS_FUNCTIONS, + mode: normalizeOutputMode(outputOptions.mode ?? mode), + mock: outputOptions.mock ?? mock ?? false, + clean: outputOptions.clean ?? clean ?? false, + prettier: outputOptions.prettier ?? prettier ?? false, + tslint: outputOptions.tslint ?? tslint ?? false, + tsconfig, + packageJson, + headers: outputOptions.headers ?? false, + override: { + ...outputOptions.override, + mock: { + arrayMin: outputOptions.override?.mock?.arrayMin ?? 1, + arrayMax: outputOptions.override?.mock?.arrayMax ?? 10, + ...(outputOptions.override?.mock ?? {}), + }, + operations: normalizeOperationsAndTags( + outputOptions.override?.operations ?? {}, + outputWorkspace, + ), + tags: normalizeOperationsAndTags( + outputOptions.override?.tags ?? {}, + outputWorkspace, + ), + mutator: normalizeMutator( + outputWorkspace, + outputOptions.override?.mutator, + ), + formData: + (!isBoolean(outputOptions.override?.formData) + ? normalizeMutator( + outputWorkspace, + outputOptions.override?.formData, + ) + : outputOptions.override?.formData) ?? true, + formUrlEncoded: + (!isBoolean(outputOptions.override?.formUrlEncoded) + ? normalizeMutator( + outputWorkspace, + outputOptions.override?.formUrlEncoded, + ) + : outputOptions.override?.formUrlEncoded) ?? true, + header: + outputOptions.override?.header === false + ? false + : isFunction(outputOptions.override?.header) + ? outputOptions.override?.header! + : getDefaultFilesHeader, + requestOptions: outputOptions.override?.requestOptions ?? true, + components: { + schemas: { + suffix: RefComponentSuffix.schemas, + ...(outputOptions.override?.components?.schemas ?? {}), + }, + responses: { + suffix: RefComponentSuffix.responses, + ...(outputOptions.override?.components?.responses ?? {}), + }, + parameters: { + suffix: RefComponentSuffix.parameters, + ...(outputOptions.override?.components?.parameters ?? {}), + }, + requestBodies: { + suffix: RefComponentSuffix.requestBodies, + ...(outputOptions.override?.components?.requestBodies ?? {}), + }, + }, + query: { + useQuery: true, + signal: true, + ...(outputOptions.override?.query ?? {}), + }, + swr: { + ...(outputOptions.override?.swr ?? {}), + }, + angular: { + provideIn: outputOptions.override?.angular?.provideIn ?? 'root', + }, + useDates: outputOptions.override?.useDates || false, + useDeprecatedOperations: + outputOptions.override?.useDeprecatedOperations ?? true, + }, + }, + hooks: options.hooks ? normalizeHooks(options.hooks) : {}, + }; + + if (!normalizedOptions.input.target) { + createLogger().error(chalk.red(`Config require an input target`)); + process.exit(1); + } + + if (!normalizedOptions.output.target && !normalizedOptions.output.schemas) { + createLogger().error( + chalk.red(`Config require an output target or schemas`), + ); + process.exit(1); + } + + return normalizedOptions; +}; + +const parserDefaultOptions = { + validate: true, + resolve: { github: githubResolver }, +} as SwaggerParserOptions; + +const normalizeMutator = ( + workspace: string, + mutator?: Mutator, +): NormalizedMutator | undefined => { + if (isObject(mutator)) { + if (!mutator.path) { + createLogger().error(chalk.red(`Mutator need a path`)); + process.exit(1); + } + + return { + ...mutator, + path: resolve(workspace, mutator.path), + default: (mutator.default || !mutator.name) ?? false, + }; + } + + if (isString(mutator)) { + return { + path: resolve(workspace, mutator), + default: true, + }; + } + + return mutator; +}; + +const normalizePathOrUrl = (path: T, workspace: string) => { + if (isString(path) && !isUrl(path)) { + return normalizePath(path, workspace); + } + + return path; +}; + +export const normalizePath = (path: T, workspace: string) => { + if (!isString(path)) { + return path; + } + return resolve(workspace, path); +}; + +const normalizeOperationsAndTags = ( + operationsOrTags: { + [key: string]: OperationOptions; + }, + workspace: string, +): { + [key: string]: NormalizedOperationOptions; +} => { + return Object.fromEntries( + Object.entries(operationsOrTags).map( + ([ + key, + { + transformer, + mutator, + formData, + formUrlEncoded, + requestOptions, + ...rest + }, + ]) => { + return [ + key, + { + ...rest, + ...(transformer + ? { transformer: normalizePath(transformer, workspace) } + : {}), + ...(mutator + ? { mutator: normalizeMutator(workspace, mutator) } + : {}), + ...(formData + ? { + formData: !isBoolean(formData) + ? normalizeMutator(workspace, formData) + : formData, + } + : {}), + ...(formUrlEncoded + ? { + formUrlEncoded: !isBoolean(formUrlEncoded) + ? normalizeMutator(workspace, formUrlEncoded) + : formUrlEncoded, + } + : {}), + }, + ]; + }, + ), + ); +}; + +const normalizeOutputMode = (mode?: OutputMode): OutputMode => { + if (!mode) { + return OutputMode.SINGLE; + } + + if (!Object.values(OutputMode).includes(mode)) { + createLogger().warn(chalk.yellow(`Unknown the provided mode => ${mode}`)); + return OutputMode.SINGLE; + } + + return mode; +}; + +const normalizeHooks = (hooks: HooksOptions): NormalizedHookOptions => { + const keys = Object.keys(hooks) as unknown as Hook[]; + + return keys.reduce((acc, key: Hook) => { + if (isString(hooks[key])) { + return { + ...acc, + [key]: [hooks[key]] as string[], + }; + } else if (Array.isArray(hooks[key])) { + return { + ...acc, + [key]: hooks[key] as string[], + }; + } else if (isFunction(hooks[key])) { + return { + ...acc, + [key]: [hooks[key]] as HookFunction[], + }; + } + + return acc; + }, {} as NormalizedHookOptions); +}; + +export const getDefaultFilesHeader = ({ + title, + description, + version, +}: InfoObject) => [ + `Generated by ${pkg.name} v${pkg.version} 🍺`, + `Do not edit manually.`, + ...(title ? [title] : []), + ...(description ? [description] : []), + ...(version ? [`OpenAPI spec version: ${version}`] : []), +]; diff --git a/packages/orval/src/utils/package-json.ts b/packages/orval/src/utils/package-json.ts new file mode 100644 index 000000000..a2b3b1d47 --- /dev/null +++ b/packages/orval/src/utils/package-json.ts @@ -0,0 +1,28 @@ +import { PackageJson } from '@orval/core'; +import findUp from 'find-up'; +import { existsSync } from 'fs-extra'; +import { normalizePath } from './options'; + +export const loadPackageJson = async ( + packageJson?: string, + workspace = process.cwd(), +): Promise => { + if (!packageJson) { + const pkgPath = await findUp(['package.json'], { + cwd: workspace, + }); + if (pkgPath) { + const pkg = await import(pkgPath); + return pkg; + } + return; + } + + const normalizedPath = normalizePath(packageJson, workspace); + if (existsSync(normalizedPath)) { + const pkg = await import(normalizedPath); + + return pkg; + } + return; +}; diff --git a/packages/orval/src/utils/request.ts b/packages/orval/src/utils/request.ts new file mode 100644 index 000000000..1280427be --- /dev/null +++ b/packages/orval/src/utils/request.ts @@ -0,0 +1,38 @@ +import http from 'http'; +import https from 'https'; + +export type Response = { + status: http.IncomingMessage['statusCode']; + headers: http.IncomingMessage['headers']; + body: T; +}; + +export const request = ( + urlOptions: string | https.RequestOptions | URL, + data?: string, +): Promise> => { + return new Promise((resolve, reject) => { + const req = https.request(urlOptions, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk.toString())); + res.on('error', reject); + res.on('end', () => { + const response = { + status: res.statusCode, + headers: res.headers, + body: JSON.parse(body), + }; + if (res.statusCode && res.statusCode >= 200 && res.statusCode <= 299) { + resolve(response); + } else { + reject(response); + } + }); + }); + req.on('error', reject); + if (data) { + req.write(data, 'binary'); + } + req.end(); + }); +}; diff --git a/packages/orval/src/utils/tsconfig.ts b/packages/orval/src/utils/tsconfig.ts new file mode 100644 index 000000000..4cef226b4 --- /dev/null +++ b/packages/orval/src/utils/tsconfig.ts @@ -0,0 +1,41 @@ +import { isObject, isString, isUndefined, Tsconfig } from '@orval/core'; +import findUp from 'find-up'; +import { existsSync } from 'fs-extra'; +import { parse } from 'tsconfck'; +import { normalizePath } from './options'; + +export const loadTsconfig = async ( + tsconfig?: Tsconfig | string, + workspace = process.cwd(), +): Promise => { + if (isUndefined(tsconfig)) { + const configPath = await findUp(['tsconfig.json', 'jsconfig.json'], { + cwd: workspace, + }); + if (configPath) { + const config = await parse(configPath); + return config.tsconfig; + } + return; + } + + if (isString(tsconfig)) { + const normalizedPath = normalizePath(tsconfig, workspace); + if (existsSync(normalizedPath)) { + const config = await parse(normalizedPath); + + const tsconfig = + config.referenced?.find( + ({ tsconfigFile }) => tsconfigFile === normalizedPath, + )?.tsconfig || config.tsconfig; + + return tsconfig; + } + return; + } + + if (isObject(tsconfig)) { + return tsconfig; + } + return; +}; diff --git a/packages/orval/src/utils/watcher.ts b/packages/orval/src/utils/watcher.ts new file mode 100644 index 000000000..43e28ef55 --- /dev/null +++ b/packages/orval/src/utils/watcher.ts @@ -0,0 +1,42 @@ +import { log } from '@orval/core'; +import chalk from 'chalk'; + +export const startWatcher = async ( + watchOptions: boolean | string | (string | boolean)[], + watchFn: () => Promise, + defaultTarget: string | string[] = '.', +) => { + if (!watchOptions) return; + const { watch } = await import('chokidar'); + + const ignored = ['**/{.git,node_modules}/**']; + + const watchPaths = + typeof watchOptions === 'boolean' + ? defaultTarget + : Array.isArray(watchOptions) + ? watchOptions.filter((path): path is string => typeof path === 'string') + : watchOptions; + + log( + `Watching for changes in ${ + Array.isArray(watchPaths) + ? watchPaths.map((v) => '"' + v + '"').join(' | ') + : '"' + watchPaths + '"' + }`, + ); + + const watcher = watch(watchPaths, { + ignorePermissionErrors: true, + ignored, + }); + watcher.on('all', async (type, file) => { + log(`Change detected: ${type} ${file}`); + + try { + await watchFn(); + } catch (e) { + log(chalk.red(e)); + } + }); +}; diff --git a/packages/orval/src/write-specs.ts b/packages/orval/src/write-specs.ts new file mode 100644 index 000000000..358092032 --- /dev/null +++ b/packages/orval/src/write-specs.ts @@ -0,0 +1,171 @@ +import { + createSuccessMessage, + getFileInfo, + getSpecName, + isRootKey, + jsDoc, + log, + NormalizedOptions, + OutputMode, + relativeSafe, + writeSchemas, + writeSingleMode, + WriteSpecsBuilder, + writeSplitMode, + writeSplitTagsMode, + writeTagsMode, +} from '@orval/core'; +import chalk from 'chalk'; +import execa from 'execa'; +import { appendFile, outputFile, pathExists, readFile } from 'fs-extra'; +import uniq from 'lodash.uniq'; +import { InfoObject } from 'openapi3-ts'; +import { join } from 'upath'; +import { executeHook } from './utils'; + +const getHeader = ( + option: false | ((info: InfoObject) => string | string[]), + info: InfoObject, +): string => { + if (!option) { + return ''; + } + + const header = option(info); + + return Array.isArray(header) ? jsDoc({ description: header }) : header; +}; + +export const writeSpecs = async ( + builder: WriteSpecsBuilder, + workspace: string, + options: NormalizedOptions, + projectName?: string, +) => { + const { info, schemas, target } = builder; + const { output } = options; + const projectTitle = projectName || info.title; + + const specsName = Object.keys(schemas).reduce((acc, specKey) => { + const basePath = getSpecName(specKey, target); + const name = basePath.slice(1).split('/').join('-'); + + acc[specKey] = name; + + return acc; + }, {} as Record); + + const header = getHeader(output.override.header, info); + + if (output.schemas) { + const rootSchemaPath = output.schemas; + + await Promise.all( + Object.entries(schemas).map(([specKey, schemas]) => { + const schemaPath = !isRootKey(specKey, target) + ? join(rootSchemaPath, specsName[specKey]) + : rootSchemaPath; + + return writeSchemas({ + schemaPath, + schemas, + target, + specsName, + isRootKey: isRootKey(specKey, target), + header, + }); + }), + ); + } + + let implementationPaths: string[] = []; + + if (output.target) { + const writeMode = getWriteMode(output.mode); + implementationPaths = await writeMode({ + builder, + workspace, + output, + specsName, + header, + }); + } + + if (output.workspace) { + const workspacePath = output.workspace; + let imports = implementationPaths + .filter((path) => !path.endsWith('.msw.ts')) + .map((path) => + relativeSafe(workspacePath, getFileInfo(path).pathWithoutExtension), + ); + + if (output.schemas) { + imports.push( + relativeSafe(workspacePath, getFileInfo(output.schemas).dirname), + ); + } + + const indexFile = join(workspacePath, '/index.ts'); + + if (await pathExists(indexFile)) { + const data = await readFile(indexFile, 'utf8'); + const importsNotDeclared = imports.filter((imp) => !data.includes(imp)); + await appendFile( + indexFile, + uniq(importsNotDeclared) + .map((imp) => `export * from '${imp}';`) + .join('\n') + '\n', + ); + } else { + await outputFile( + indexFile, + uniq(imports) + .map((imp) => `export * from '${imp}';`) + .join('\n') + '\n', + ); + } + + implementationPaths = [indexFile, ...implementationPaths]; + } + + const paths = [ + ...(output.schemas ? [getFileInfo(output.schemas).dirname] : []), + ...implementationPaths, + ]; + + if (options.hooks.afterAllFilesWrite) { + await executeHook( + 'afterAllFilesWrite', + options.hooks.afterAllFilesWrite, + paths, + ); + } + + if (output.prettier) { + try { + await execa('prettier', ['--write', ...paths]); + } catch (e) { + log( + chalk.yellow( + `⚠️ ${projectTitle ? `${projectTitle} - ` : ''}Prettier not found`, + ), + ); + } + } + + createSuccessMessage(projectTitle); +}; + +const getWriteMode = (mode: OutputMode) => { + switch (mode) { + case OutputMode.SPLIT: + return writeSplitMode; + case OutputMode.TAGS: + return writeTagsMode; + case OutputMode.TAGS_SPLIT: + return writeSplitTagsMode; + case OutputMode.SINGLE: + default: + return writeSingleMode; + } +}; diff --git a/packages/orval/tsconfig.json b/packages/orval/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/orval/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/query/README.md b/packages/query/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/query/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/query/package.json b/packages/query/package.json new file mode 100644 index 000000000..b2bdc2cea --- /dev/null +++ b/packages/query/package.json @@ -0,0 +1,22 @@ +{ + "name": "@orval/query", + "version": "6.11.0-alpha.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts" + }, + "dependencies": { + "@orval/core": "6.11.0-alpha.1", + "lodash.omitby": "^4.6.0" + }, + "devDependencies": { + "@types/lodash.omitby": "^4.6.7" + } +} diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts new file mode 100644 index 000000000..c2a62a5ee --- /dev/null +++ b/packages/query/src/index.ts @@ -0,0 +1,945 @@ +import { + camel, + ClientBuilder, + ClientDependenciesBuilder, + ClientFooterBuilder, + ClientGeneratorsBuilder, + ClientHeaderBuilder, + ClientTitleBuilder, + generateFormDataAndUrlEncodedFunction, + generateMutatorConfig, + generateMutatorRequestOptions, + generateOptions, + generateVerbImports, + GeneratorDependency, + GeneratorMutator, + GeneratorOptions, + GeneratorVerbOptions, + GetterParams, + GetterProps, + GetterPropType, + GetterResponse, + isObject, + isSyntheticDefaultImportsAllow, + OutputClient, + OutputClientFunc, + pascal, + stringify, + toObjectString, + Verbs, + VERBS_WITH_BODY, +} from '@orval/core'; +import omitBy from 'lodash.omitby'; + +const AXIOS_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { + name: 'axios', + default: true, + values: true, + syntheticDefaultImport: true, + }, + { name: 'AxiosRequestConfig' }, + { name: 'AxiosResponse' }, + { name: 'AxiosError' }, + ], + dependency: 'axios', + }, +]; + +const SVELTE_QUERY_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { name: 'useQuery', values: true }, + { name: 'useInfiniteQuery', values: true }, + { name: 'useMutation', values: true }, + { name: 'UseQueryOptions' }, + { + name: 'UseInfiniteQueryOptions', + }, + { name: 'UseMutationOptions' }, + { name: 'QueryFunction' }, + { name: 'MutationFunction' }, + { name: 'UseQueryStoreResult' }, + { name: 'UseInfiniteQueryStoreResult' }, + { name: 'QueryKey' }, + ], + dependency: '@sveltestack/svelte-query', + }, +]; + +export const getSvelteQueryDependencies: ClientDependenciesBuilder = ( + hasGlobalMutator: boolean, +) => [ + ...(!hasGlobalMutator ? AXIOS_DEPENDENCIES : []), + ...SVELTE_QUERY_DEPENDENCIES, +]; + +const REACT_QUERY_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { name: 'useQuery', values: true }, + { name: 'useInfiniteQuery', values: true }, + { name: 'useMutation', values: true }, + { name: 'UseQueryOptions' }, + { name: 'UseInfiniteQueryOptions' }, + { name: 'UseMutationOptions' }, + { name: 'QueryFunction' }, + { name: 'MutationFunction' }, + { name: 'UseQueryResult' }, + { name: 'UseInfiniteQueryResult' }, + { name: 'QueryKey' }, + ], + dependency: 'react-query', + }, +]; +const REACT_QUERY_V4_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { name: 'useQuery', values: true }, + { name: 'useInfiniteQuery', values: true }, + { name: 'useMutation', values: true }, + { name: 'UseQueryOptions' }, + { name: 'UseInfiniteQueryOptions' }, + { name: 'UseMutationOptions' }, + { name: 'QueryFunction' }, + { name: 'MutationFunction' }, + { name: 'UseQueryResult' }, + { name: 'UseInfiniteQueryResult' }, + { name: 'QueryKey' }, + ], + dependency: '@tanstack/react-query', + }, +]; + +export const getReactQueryDependencies: ClientDependenciesBuilder = ( + hasGlobalMutator, + packageJson, +) => { + const hasReactQuery = + packageJson?.dependencies?.['react-query'] ?? + packageJson?.devDependencies?.['react-query']; + const hasReactQueryV4 = + packageJson?.dependencies?.['@tanstack/react-query'] ?? + packageJson?.devDependencies?.['@tanstack/react-query']; + + return [ + ...(!hasGlobalMutator ? AXIOS_DEPENDENCIES : []), + ...(hasReactQuery && !hasReactQueryV4 + ? REACT_QUERY_DEPENDENCIES + : REACT_QUERY_V4_DEPENDENCIES), + ]; +}; + +const VUE_QUERY_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { name: 'useQuery', values: true }, + { name: 'useInfiniteQuery', values: true }, + { name: 'useMutation', values: true }, + ], + dependency: 'vue-query', + }, + { + exports: [ + { name: 'UseQueryOptions' }, + { name: 'UseInfiniteQueryOptions' }, + { name: 'UseMutationOptions' }, + { name: 'QueryFunction' }, + { name: 'MutationFunction' }, + { name: 'UseQueryResult' }, + { name: 'UseInfiniteQueryResult' }, + { name: 'QueryKey' }, + ], + dependency: 'vue-query/types', + }, + { + exports: [{ name: 'UseQueryReturnType' }], + dependency: 'vue-query/lib/vue/useBaseQuery', + }, +]; + +export const getVueQueryDependencies: ClientDependenciesBuilder = ( + hasGlobalMutator: boolean, +) => [ + ...(!hasGlobalMutator ? AXIOS_DEPENDENCIES : []), + ...VUE_QUERY_DEPENDENCIES, +]; + +const generateRequestOptionsArguments = ({ + isRequestOptions, + hasSignal, +}: { + isRequestOptions: boolean; + hasSignal: boolean; +}) => { + if (isRequestOptions) { + return 'options?: AxiosRequestConfig\n'; + } + + return hasSignal ? 'signal?: AbortSignal\n' : ''; +}; + +const generateQueryRequestFunction = ( + { + headers, + queryParams, + operationName, + response, + mutator, + body, + props, + verb, + formData, + formUrlEncoded, + override, + }: GeneratorVerbOptions, + { route, context }: GeneratorOptions, +) => { + const isRequestOptions = override.requestOptions !== false; + const isFormData = override.formData !== false; + const isFormUrlEncoded = override.formUrlEncoded !== false; + const hasSignal = !!override.query.signal; + + const isSyntheticDefaultImportsAllowed = isSyntheticDefaultImportsAllow( + context.tsconfig, + ); + const isExactOptionalPropertyTypes = + !!context.tsconfig?.compilerOptions?.exactOptionalPropertyTypes; + const isBodyVerb = VERBS_WITH_BODY.includes(verb); + + const bodyForm = generateFormDataAndUrlEncodedFunction({ + formData, + formUrlEncoded, + body, + isFormData, + isFormUrlEncoded, + }); + + if (mutator) { + const mutatorConfig = generateMutatorConfig({ + route, + body, + headers, + queryParams, + response, + verb, + isFormData, + isFormUrlEncoded, + isBodyVerb, + hasSignal, + isExactOptionalPropertyTypes, + }); + + const propsImplementation = + mutator?.bodyTypeName && body.definition + ? toObjectString(props, 'implementation').replace( + new RegExp(`(\\w*):\\s?${body.definition}`), + `$1: ${mutator.bodyTypeName}<${body.definition}>`, + ) + : toObjectString(props, 'implementation'); + + const requestOptions = isRequestOptions + ? generateMutatorRequestOptions( + override.requestOptions, + mutator.hasSecondArg, + ) + : ''; + + if (mutator.isHook) { + return `export const use${pascal(operationName)}Hook = () => { + const ${operationName} = ${mutator.name}<${ + response.definition.success || 'unknown' + }>(); + + return (\n ${propsImplementation}\n ${ + isRequestOptions && mutator.hasSecondArg + ? `options?: SecondParameter>,` + : '' + }${ + !isBodyVerb && hasSignal ? 'signal?: AbortSignal\n' : '' + }) => {${bodyForm} + return ${operationName}( + ${mutatorConfig}, + ${requestOptions}); + } + } + `; + } + + return `export const ${operationName} = (\n ${propsImplementation}\n ${ + isRequestOptions && mutator.hasSecondArg + ? `options?: SecondParameter,` + : '' + }${ + !isBodyVerb && hasSignal ? 'signal?: AbortSignal\n' : '' + }) => {${bodyForm} + return ${mutator.name}<${response.definition.success || 'unknown'}>( + ${mutatorConfig}, + ${requestOptions}); + } + `; + } + + const options = generateOptions({ + route, + body, + headers, + queryParams, + response, + verb, + requestOptions: override?.requestOptions, + isFormData, + isFormUrlEncoded, + isExactOptionalPropertyTypes, + hasSignal, + }); + + const optionsArgs = generateRequestOptionsArguments({ + isRequestOptions, + hasSignal, + }); + + return `export const ${operationName} = (\n ${toObjectString( + props, + 'implementation', + )} ${optionsArgs} ): Promise> => {${bodyForm} + return axios${ + !isSyntheticDefaultImportsAllowed ? '.default' : '' + }.${verb}(${options}); + } +`; +}; + +type QueryType = 'infiniteQuery' | 'query'; + +const QueryType = { + INFINITE: 'infiniteQuery' as QueryType, + QUERY: 'query' as QueryType, +}; + +const INFINITE_QUERY_PROPERTIES = ['getNextPageParam', 'getPreviousPageParam']; + +const generateQueryOptions = ({ + params, + options, + type, +}: { + params: GetterParams; + options?: object | boolean; + type: QueryType; +}) => { + if (options === false) { + return ''; + } + + const queryConfig = isObject(options) + ? ` ${stringify( + omitBy(options, (_, key) => { + if ( + type !== QueryType.INFINITE && + INFINITE_QUERY_PROPERTIES.includes(key) + ) { + return true; + } + return false; + }), + )?.slice(1, -1)}` + : ''; + + if (!params.length) { + if (options) { + return `{${queryConfig} ...queryOptions}`; + } + + return 'queryOptions'; + } + + return `{${ + !isObject(options) || !options.hasOwnProperty('enabled') + ? `enabled: !!(${params.map(({ name }) => name).join(' && ')}),` + : '' + }${queryConfig} ...queryOptions}`; +}; + +const getQueryArgumentsRequestType = (mutator?: GeneratorMutator) => { + if (!mutator) { + return `axios?: AxiosRequestConfig`; + } + + if (mutator.hasSecondArg && !mutator.isHook) { + return `request?: SecondParameter`; + } + + if (mutator.hasSecondArg && mutator.isHook) { + return `request?: SecondParameter>`; + } + + return ''; +}; + +const generateQueryArguments = ({ + operationName, + definitions, + mutator, + isRequestOptions, + type, +}: { + operationName: string; + definitions: string; + mutator?: GeneratorMutator; + isRequestOptions: boolean; + type?: QueryType; +}) => { + const isMutatorHook = mutator?.isHook; + const definition = type + ? `Use${pascal(type)}Options` + : `typeof ${operationName}` + }>>, TError, TData>` + : `UseMutationOptions` + : `typeof ${operationName}` + }>>, TError,${ + definitions ? `{${definitions}}` : 'TVariables' + }, TContext>`; + + if (!isRequestOptions) { + return `${type ? 'queryOptions' : 'mutationOptions'}?: ${definition}`; + } + + const requestType = getQueryArgumentsRequestType(mutator); + + return `options?: { ${ + type ? 'query' : 'mutation' + }?:${definition}, ${requestType}}\n`; +}; + +const generateQueryReturnType = ({ + outputClient, + type, + isMutatorHook, + operationName, +}: { + outputClient: OutputClient | OutputClientFunc; + type: QueryType; + isMutatorHook?: boolean; + operationName: string; +}) => { + switch (outputClient) { + case OutputClient.SVELTE_QUERY: + return `Use${pascal(type)}StoreResult` + : `typeof ${operationName}` + }>>, TError, TData, QueryKey>`; + case OutputClient.VUE_QUERY: + return ` UseQueryReturnType>`; + case OutputClient.REACT_QUERY: + default: + return ` Use${pascal(type)}Result`; + } +}; + +const getQueryOptions = ({ + isRequestOptions, + mutator, + isExactOptionalPropertyTypes, + hasSignal, +}: { + isRequestOptions: boolean; + mutator?: GeneratorMutator; + isExactOptionalPropertyTypes: boolean; + hasSignal: boolean; +}) => { + if (!mutator && isRequestOptions) { + if (!hasSignal) { + return 'axiosOptions'; + } + return `{ ${ + isExactOptionalPropertyTypes ? '...(signal ? { signal } : {})' : 'signal' + }, ...axiosOptions }`; + } + + if (mutator?.hasSecondArg && isRequestOptions) { + if (!hasSignal) { + return 'requestOptions'; + } + + return 'requestOptions, signal'; + } + + if (hasSignal) { + return 'signal'; + } + + return ''; +}; + +const getHookOptions = ({ + isRequestOptions, + mutator, +}: { + isRequestOptions: boolean; + mutator?: GeneratorMutator; +}) => { + if (!isRequestOptions) { + return ''; + } + + let value = 'const {query: queryOptions'; + + if (!mutator) { + value += ', axios: axiosOptions'; + } + + if (mutator?.hasSecondArg) { + value += ', request: requestOptions'; + } + + value += '} = options ?? {};'; + + return value; +}; + +const getQueryFnArguments = ({ + hasQueryParam, + hasSignal, +}: { + hasQueryParam: boolean; + hasSignal: boolean; +}) => { + if (!hasQueryParam && !hasSignal) { + return ''; + } + + if (hasQueryParam) { + if (hasSignal) { + return '{ signal, pageParam }'; + } + + return '{ pageParam }'; + } + + return '{ signal }'; +}; + +const generateQueryImplementation = ({ + queryOption: { name, queryParam, options, type }, + operationName, + queryKeyFnName, + queryProperties, + queryKeyProperties, + params, + props, + mutator, + isRequestOptions, + response, + outputClient, + isExactOptionalPropertyTypes, + hasSignal, +}: { + queryOption: { + name: string; + options?: object | boolean; + type: QueryType; + queryParam?: string; + }; + isRequestOptions: boolean; + operationName: string; + queryKeyFnName: string; + queryProperties: string; + queryKeyProperties: string; + params: GetterParams; + props: GetterProps; + response: GetterResponse; + mutator?: GeneratorMutator; + outputClient: OutputClient | OutputClientFunc; + isExactOptionalPropertyTypes: boolean; + hasSignal: boolean; +}) => { + const queryProps = toObjectString(props, 'implementation'); + const httpFunctionProps = queryParam + ? props + .map(({ name }) => + name === 'params' ? `{ ${queryParam}: pageParam, ...params }` : name, + ) + .join(',') + : queryProperties; + + const returnType = generateQueryReturnType({ + outputClient, + type, + isMutatorHook: mutator?.isHook, + operationName, + }); + + let errorType = `AxiosError<${response.definition.errors || 'unknown'}>`; + + if (mutator) { + errorType = mutator.hasErrorType + ? `${mutator.default ? pascal(operationName) : ''}ErrorType<${ + response.definition.errors || 'unknown' + }>` + : response.definition.errors || 'unknown'; + } + + const dataType = mutator?.isHook + ? `ReturnType` + : `typeof ${operationName}`; + + const queryArguments = generateQueryArguments({ + operationName, + definitions: '', + mutator, + isRequestOptions, + type, + }); + + const queryOptions = getQueryOptions({ + isRequestOptions, + isExactOptionalPropertyTypes, + mutator, + hasSignal, + }); + + const hookOptions = getHookOptions({ + isRequestOptions, + mutator, + }); + + const queryFnArguments = getQueryFnArguments({ + hasQueryParam: + !!queryParam && props.some(({ type }) => type === 'queryParam'), + hasSignal, + }); + + return ` +export type ${pascal( + name, + )}QueryResult = NonNullable>> +export type ${pascal(name)}QueryError = ${errorType} + +export const ${camel( + `use-${name}`, + )} = >, TError = ${errorType}>(\n ${queryProps} ${queryArguments}\n ): ${returnType} & { queryKey: QueryKey } => { + + ${hookOptions} + + const queryKey = queryOptions?.queryKey ?? ${queryKeyFnName}(${queryKeyProperties}); + + ${ + mutator?.isHook + ? `const ${operationName} = use${pascal(operationName)}Hook();` + : '' + } + + const queryFn: QueryFunction` + : `typeof ${operationName}` + }>>> = (${queryFnArguments}) => ${operationName}(${httpFunctionProps}${ + httpFunctionProps ? ', ' : '' + }${queryOptions}); + + const query = ${camel(`use-${type}`)}` + : `typeof ${operationName}` + }>>, TError, TData>(queryKey, queryFn, ${generateQueryOptions({ + params, + options, + type, + })}) as ${returnType} & { queryKey: QueryKey }; + + query.queryKey = queryKey; + + return query; +}\n`; +}; + +const generateQueryHook = ( + { + queryParams, + operationName, + body, + props, + verb, + params, + override, + mutator, + response, + operationId, + }: GeneratorVerbOptions, + { route, override: { operations = {} }, context }: GeneratorOptions, + outputClient: OutputClient | OutputClientFunc, +) => { + const query = override?.query; + const isRequestOptions = override?.requestOptions !== false; + const operationQueryOptions = operations[operationId]?.query; + const isExactOptionalPropertyTypes = + !!context.tsconfig?.compilerOptions?.exactOptionalPropertyTypes; + + if ( + verb === Verbs.GET || + operationQueryOptions?.useInfinite || + operationQueryOptions?.useQuery + ) { + const queryProperties = props + .map(({ name, type }) => + type === GetterPropType.BODY ? body.implementation : name, + ) + .join(','); + + const queryKeyProperties = props + .filter((prop) => prop.type !== GetterPropType.HEADER) + .map(({ name, type }) => + type === GetterPropType.BODY ? body.implementation : name, + ) + .join(','); + + const queries = [ + ...(query?.useInfinite + ? [ + { + name: camel(`${operationName}-infinite`), + options: query?.options, + type: QueryType.INFINITE, + queryParam: query?.useInfiniteQueryParam, + }, + ] + : []), + ...((!query?.useQuery && !query?.useInfinite) || query?.useQuery + ? [ + { + name: operationName, + options: query?.options, + type: QueryType.QUERY, + }, + ] + : []), + ]; + + const queryKeyFnName = camel(`get-${operationName}-queryKey`); + const queryKeyProps = toObjectString( + props.filter((prop) => prop.type !== GetterPropType.HEADER), + 'implementation', + ); + + return `export const ${queryKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ + queryParams ? ', ...(params ? [params]: [])' : '' + }${body.implementation ? `, ${body.implementation}` : ''}]; + + ${queries.reduce( + (acc, queryOption) => + acc + + generateQueryImplementation({ + queryOption, + operationName, + queryKeyFnName, + queryProperties, + queryKeyProperties, + params, + props, + mutator, + isRequestOptions, + response, + outputClient, + isExactOptionalPropertyTypes, + hasSignal: !!query.signal, + }), + '', + )} +`; + } + + const definitions = props + .map(({ definition, type }) => + type === GetterPropType.BODY + ? mutator?.bodyTypeName + ? `data: ${mutator.bodyTypeName}<${body.definition}>` + : `data: ${body.definition}` + : definition, + ) + .join(';'); + + const properties = props + .map(({ name, type }) => (type === GetterPropType.BODY ? 'data' : name)) + .join(','); + + let errorType = `AxiosError<${response.definition.errors || 'unknown'}>`; + + if (mutator) { + errorType = mutator.hasErrorType + ? `${mutator.default ? pascal(operationName) : ''}ErrorType<${ + response.definition.errors || 'unknown' + }>` + : response.definition.errors || 'unknown'; + } + + const dataType = mutator?.isHook + ? `ReturnType` + : `typeof ${operationName}`; + + return ` + export type ${pascal( + operationName, + )}MutationResult = NonNullable>> + ${ + body.definition + ? `export type ${pascal(operationName)}MutationBody = ${ + mutator?.bodyTypeName + ? `${mutator.bodyTypeName}<${body.definition}>` + : body.definition + }` + : '' + } + export type ${pascal(operationName)}MutationError = ${errorType} + + export const ${camel(`use-${operationName}`)} = (${generateQueryArguments({ + operationName, + definitions, + mutator, + isRequestOptions, + })}) => { + ${ + isRequestOptions + ? `const {mutation: mutationOptions${ + !mutator + ? `, axios: axiosOptions` + : mutator?.hasSecondArg + ? ', request: requestOptions' + : '' + }} = options ?? {};` + : '' + } + + ${ + mutator?.isHook + ? `const ${operationName} = use${pascal(operationName)}Hook()` + : '' + } + + + const mutationFn: MutationFunction>, ${ + definitions ? `{${definitions}}` : 'TVariables' + }> = (${properties ? 'props' : ''}) => { + ${properties ? `const {${properties}} = props ?? {};` : ''} + + return ${operationName}(${properties}${properties ? ',' : ''}${ + isRequestOptions + ? !mutator + ? `axiosOptions` + : mutator?.hasSecondArg + ? 'requestOptions' + : '' + : '' + }) + } + + return useMutation>, TError, ${ + definitions ? `{${definitions}}` : 'TVariables' + }, TContext>(mutationFn, mutationOptions) + } + `; +}; + +export const generateQueryTitle: ClientTitleBuilder = () => ''; + +export const generateQueryHeader: ClientHeaderBuilder = ({ + isRequestOptions, + isMutator, + hasAwaitedType, +}) => { + return `${ + !hasAwaitedType + ? `type AwaitedInput = PromiseLike | T;\n + type Awaited = O extends AwaitedInput ? T : never;\n\n` + : '' + } +${ + isRequestOptions && isMutator + ? `// eslint-disable-next-line + type SecondParameter any> = T extends ( + config: any, + args: infer P, +) => any + ? P + : never;\n\n` + : '' +}`; +}; + +export const generateQueryFooter: ClientFooterBuilder = () => ''; + +export const generateQuery: ClientBuilder = ( + verbOptions, + options, + outputClient, +) => { + const imports = generateVerbImports(verbOptions); + const functionImplementation = generateQueryRequestFunction( + verbOptions, + options, + ); + const hookImplementation = generateQueryHook( + verbOptions, + options, + outputClient, + ); + + return { + implementation: `${functionImplementation}\n\n${hookImplementation}`, + imports, + }; +}; + +const reactQueryClientBuilder: ClientGeneratorsBuilder = { + client: generateQuery, + header: generateQueryHeader, + dependencies: getReactQueryDependencies, + footer: generateQueryFooter, + title: generateQueryTitle, +}; + +const svelteQueryClientBuilder: ClientGeneratorsBuilder = { + client: generateQuery, + header: generateQueryHeader, + dependencies: getSvelteQueryDependencies, + footer: generateQueryFooter, + title: generateQueryTitle, +}; + +const vueQueryClientBuilder: ClientGeneratorsBuilder = { + client: generateQuery, + header: generateQueryHeader, + dependencies: getVueQueryDependencies, + footer: generateQueryFooter, + title: generateQueryTitle, +}; + +export const builder = ({ + type, +}: { + type: 'react-query' | 'vue-query' | 'svelte-query'; +}) => { + switch (type) { + case 'react-query': + return reactQueryClientBuilder; + case 'vue-query': + return vueQueryClientBuilder; + case 'svelte-query': + return svelteQueryClientBuilder; + } +}; + +export default builder; diff --git a/packages/query/src/types.ts b/packages/query/src/types.ts new file mode 100644 index 000000000..24e8813b3 --- /dev/null +++ b/packages/query/src/types.ts @@ -0,0 +1,7 @@ +export type QueryOptions = { + useQuery?: boolean; + useInfinite?: boolean; + useInfiniteQueryParam?: string; + options?: any; + signal?: boolean; +}; diff --git a/packages/query/tsconfig.json b/packages/query/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/query/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/swr/README.md b/packages/swr/README.md new file mode 100644 index 000000000..8e1596ee8 --- /dev/null +++ b/packages/swr/README.md @@ -0,0 +1,28 @@ +[![npm version](https://badge.fury.io/js/orval.svg)](https://badge.fury.io/js/orval) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![tests](https://github.com/anymaniax/orval/actions/workflows/tests.yaml/badge.svg)](https://github.com/anymaniax/orval/actions/workflows/tests.yaml) + +

+ orval - Restfull Client Generator +

+

+ Visit orval.dev for docs, guides, API and beer! +

+ +### Code Generation + +`orval` is able to generate client with appropriate type-signatures (TypeScript) from any valid OpenAPI v3 or Swagger v2 specification, either in `yaml` or `json` formats. + +`Generate`, `valid`, `cache` and `mock` in your React, Vue, Svelte and Angular applications all with your OpenAPI specification. + +### Samples + +You can find below some samples + +- [react app](https://github.com/anymaniax/orval/tree/master/samples/react-app) +- [react query](https://github.com/anymaniax/orval/tree/master/samples/react-query) +- [svelte query](https://github.com/anymaniax/orval/tree/master/samples/svelte-query) +- [vue query](https://github.com/anymaniax/orval/tree/master/samples/vue-query) +- [react app with swr](https://github.com/anymaniax/orval/tree/master/samples/react-app-with-swr) +- [nx fastify react](https://github.com/anymaniax/orval/tree/master/samples/nx-fastify-react) +- [angular app](https://github.com/anymaniax/orval/tree/master/samples/angular-app) diff --git a/packages/swr/package.json b/packages/swr/package.json new file mode 100644 index 000000000..a7a5cc991 --- /dev/null +++ b/packages/swr/package.json @@ -0,0 +1,18 @@ +{ + "name": "@orval/swr", + "version": "6.11.0-alpha.1", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup ./src/index.ts --target node12 --minify --clean --dts --splitting", + "dev": "tsup ./src/index.ts --target node12 --clean --watch src", + "lint": "eslint src/**/*.ts" + }, + "dependencies": { + "@orval/core": "6.11.0-alpha.1" + } +} diff --git a/packages/swr/src/index.ts b/packages/swr/src/index.ts new file mode 100644 index 000000000..40bfc7894 --- /dev/null +++ b/packages/swr/src/index.ts @@ -0,0 +1,397 @@ +import { + camel, + ClientBuilder, + ClientDependenciesBuilder, + ClientFooterBuilder, + ClientGeneratorsBuilder, + ClientHeaderBuilder, + ClientTitleBuilder, + generateFormDataAndUrlEncodedFunction, + generateMutatorConfig, + generateMutatorRequestOptions, + generateOptions, + generateVerbImports, + GeneratorDependency, + GeneratorMutator, + GeneratorOptions, + GeneratorVerbOptions, + GetterParams, + GetterProps, + GetterPropType, + GetterResponse, + isSyntheticDefaultImportsAllow, + pascal, + stringify, + toObjectString, + Verbs, + VERBS_WITH_BODY, +} from '@orval/core'; + +const AXIOS_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { + name: 'axios', + default: true, + values: true, + syntheticDefaultImport: true, + }, + { name: 'AxiosRequestConfig' }, + { name: 'AxiosResponse' }, + { name: 'AxiosError' }, + ], + dependency: 'axios', + }, +]; + +const SWR_DEPENDENCIES: GeneratorDependency[] = [ + { + exports: [ + { name: 'useSwr', values: true, default: true }, + { name: 'SWRConfiguration' }, + { name: 'Key' }, + ], + dependency: 'swr', + }, +]; + +export const getSwrDependencies: ClientDependenciesBuilder = ( + hasGlobalMutator: boolean, +) => [...(!hasGlobalMutator ? AXIOS_DEPENDENCIES : []), ...SWR_DEPENDENCIES]; + +const generateSwrRequestFunction = ( + { + headers, + queryParams, + operationName, + response, + mutator, + body, + props, + verb, + formData, + formUrlEncoded, + override, + }: GeneratorVerbOptions, + { route, context }: GeneratorOptions, +) => { + const isRequestOptions = override?.requestOptions !== false; + const isFormData = override?.formData !== false; + const isFormUrlEncoded = override?.formUrlEncoded !== false; + const isExactOptionalPropertyTypes = + !!context.tsconfig?.compilerOptions?.exactOptionalPropertyTypes; + const isBodyVerb = VERBS_WITH_BODY.includes(verb); + const isSyntheticDefaultImportsAllowed = isSyntheticDefaultImportsAllow( + context.tsconfig, + ); + + const bodyForm = generateFormDataAndUrlEncodedFunction({ + formData, + formUrlEncoded, + body, + isFormData, + isFormUrlEncoded, + }); + + if (mutator) { + const mutatorConfig = generateMutatorConfig({ + route, + body, + headers, + queryParams, + response, + verb, + isFormData, + isFormUrlEncoded, + hasSignal: false, + isBodyVerb, + isExactOptionalPropertyTypes, + }); + + const propsImplementation = + mutator?.bodyTypeName && body.definition + ? toObjectString(props, 'implementation').replace( + new RegExp(`(\\w*):\\s?${body.definition}`), + `$1: ${mutator.bodyTypeName}<${body.definition}>`, + ) + : toObjectString(props, 'implementation'); + + const requestOptions = isRequestOptions + ? generateMutatorRequestOptions( + override?.requestOptions, + mutator.hasSecondArg, + ) + : ''; + + return `export const ${operationName} = (\n ${propsImplementation}\n ${ + isRequestOptions && mutator.hasSecondArg + ? `options?: SecondParameter` + : '' + }) => {${bodyForm} + return ${mutator.name}<${response.definition.success || 'unknown'}>( + ${mutatorConfig}, + ${requestOptions}); + } + `; + } + + const options = generateOptions({ + route, + body, + headers, + queryParams, + response, + verb, + requestOptions: override?.requestOptions, + isFormData, + isFormUrlEncoded, + isExactOptionalPropertyTypes, + hasSignal: false, + }); + + return `export const ${operationName} = (\n ${toObjectString( + props, + 'implementation', + )} ${ + isRequestOptions ? `options?: AxiosRequestConfig\n` : '' + } ): Promise> => {${bodyForm} + return axios${ + !isSyntheticDefaultImportsAllowed ? '.default' : '' + }.${verb}(${options}); + } +`; +}; + +const generateSwrArguments = ({ + operationName, + mutator, + isRequestOptions, +}: { + operationName: string; + mutator?: GeneratorMutator; + isRequestOptions: boolean; +}) => { + const definition = `SWRConfiguration>, TError> & { swrKey?: Key, enabled?: boolean }`; + + if (!isRequestOptions) { + return `swrOptions?: ${definition}`; + } + + return `options?: { swr?:${definition}, ${ + !mutator + ? `axios?: AxiosRequestConfig` + : mutator?.hasSecondArg + ? `request?: SecondParameter` + : '' + } }\n`; +}; + +const generateSwrImplementation = ({ + operationName, + swrKeyFnName, + swrProperties, + swrKeyProperties, + params, + mutator, + isRequestOptions, + response, + swrOptions, + props, +}: { + isRequestOptions: boolean; + operationName: string; + swrKeyFnName: string; + swrProperties: string; + swrKeyProperties: string; + params: GetterParams; + props: GetterProps; + response: GetterResponse; + mutator?: GeneratorMutator; + swrOptions: { options?: any }; +}) => { + const swrProps = toObjectString(props, 'implementation'); + const httpFunctionProps = swrProperties; + + const swrKeyImplementation = `const isEnabled = swrOptions?.enabled !== false${ + params.length + ? ` && !!(${params.map(({ name }) => name).join(' && ')})` + : '' + } + const swrKey = swrOptions?.swrKey ?? (() => isEnabled ? ${swrKeyFnName}(${swrKeyProperties}) : null);`; + + let errorType = `AxiosError<${response.definition.errors || 'unknown'}>`; + + if (mutator) { + errorType = mutator.hasErrorType + ? `ErrorType<${response.definition.errors || 'unknown'}>` + : response.definition.errors || 'unknown'; + } + + return ` +export type ${pascal( + operationName, + )}QueryResult = NonNullable>> +export type ${pascal(operationName)}QueryError = ${errorType} + +export const ${camel( + `use-${operationName}`, + )} = (\n ${swrProps} ${generateSwrArguments({ + operationName, + mutator, + isRequestOptions, + })}\n ) => { + + ${ + isRequestOptions + ? `const {swr: swrOptions${ + !mutator + ? `, axios: axiosOptions` + : mutator?.hasSecondArg + ? ', request: requestOptions' + : '' + }} = options ?? {}` + : '' + } + + ${swrKeyImplementation} + const swrFn = () => ${operationName}(${httpFunctionProps}${ + httpFunctionProps ? ', ' : '' + }${ + isRequestOptions + ? !mutator + ? `axiosOptions` + : mutator?.hasSecondArg + ? 'requestOptions' + : '' + : '' + }); + + const query = useSwr>, TError>(swrKey, swrFn, ${ + swrOptions.options + ? `{ + ${stringify(swrOptions.options)?.slice(1, -1)} + ...swrOptions + }` + : 'swrOptions' + }) + + return { + swrKey, + ...query + } +}\n`; +}; + +const generateSwrHook = ( + { + queryParams, + operationName, + body, + props, + verb, + params, + override, + mutator, + response, + }: GeneratorVerbOptions, + { route }: GeneratorOptions, +) => { + const isRequestOptions = override?.requestOptions !== false; + + if (verb !== Verbs.GET) { + return ''; + } + + const swrProperties = props + .map(({ name, type }) => + type === GetterPropType.BODY ? body.implementation : name, + ) + .join(','); + + const swrKeyProperties = props + .filter((prop) => prop.type !== GetterPropType.HEADER) + .map(({ name, type }) => + type === GetterPropType.BODY ? body.implementation : name, + ) + .join(','); + + const swrKeyFnName = camel(`get-${operationName}-key`); + const queryKeyProps = toObjectString( + props.filter((prop) => prop.type !== GetterPropType.HEADER), + 'implementation', + ); + + return `export const ${swrKeyFnName} = (${queryKeyProps}) => [\`${route}\`${ + queryParams ? ', ...(params ? [params]: [])' : '' + }${body.implementation ? `, ${body.implementation}` : ''}]; + + ${generateSwrImplementation({ + operationName, + swrKeyFnName, + swrProperties, + swrKeyProperties, + params, + props, + mutator, + isRequestOptions, + response, + swrOptions: override.swr, + })} +`; +}; + +export const generateSwrTitle: ClientTitleBuilder = () => ''; + +export const generateSwrHeader: ClientHeaderBuilder = ({ + isRequestOptions, + isMutator, + hasAwaitedType, +}) => + ` + ${ + !hasAwaitedType + ? `type AwaitedInput = PromiseLike | T;\n + type Awaited = O extends AwaitedInput ? T : never;\n\n` + : '' + } + ${ + isRequestOptions && isMutator + ? `// eslint-disable-next-line + type SecondParameter any> = T extends ( + config: any, + args: infer P, +) => any + ? P + : never;\n\n` + : '' + }`; + +export const generateSwrFooter: ClientFooterBuilder = () => ''; + +export const generateSwr: ClientBuilder = (verbOptions, options) => { + const imports = generateVerbImports(verbOptions); + const functionImplementation = generateSwrRequestFunction( + verbOptions, + options, + ); + const hookImplementation = generateSwrHook(verbOptions, options); + + return { + implementation: `${functionImplementation}\n\n${hookImplementation}`, + imports, + }; +}; + +const swrClientBuilder: ClientGeneratorsBuilder = { + client: generateSwr, + header: generateSwrHeader, + dependencies: getSwrDependencies, + footer: generateSwrFooter, + title: generateSwrTitle, +}; + +export const builder = () => swrClientBuilder; + +export default builder; diff --git a/packages/swr/tsconfig.json b/packages/swr/tsconfig.json new file mode 100644 index 000000000..9e25e6ece --- /dev/null +++ b/packages/swr/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/samples/angular-app/package.json b/samples/angular-app/package.json index 21b6fb860..ddff0f056 100644 --- a/samples/angular-app/package.json +++ b/samples/angular-app/package.json @@ -6,7 +6,7 @@ "start": "ng serve", "build": "ng build", "lint": "ng lint", - "generate-api": "node ../../dist/bin/orval.js" + "generate-api": "node ../../packages/orval/dist/bin/orval.js" }, "private": true, "dependencies": { @@ -30,7 +30,7 @@ "@types/node": "^12.11.1", "codelyzer": "^6.0.0", "msw": "^0.21.2", - "orval": "link:../../dist", + "orval": "link:../../../packages/orval/dist", "ts-node": "~8.3.0", "tslint": "~6.1.0", "typescript": "~4.3.5" diff --git a/samples/basic/package.json b/samples/basic/package.json index 6002869aa..b6c0d3eb9 100644 --- a/samples/basic/package.json +++ b/samples/basic/package.json @@ -12,7 +12,7 @@ "license": "ISC", "devDependencies": { "npm-run-all": "^4.1.5", - "orval": "link:../../dist" + "orval": "link:../../../packages/orval/dist" }, "dependencies": { "@faker-js/faker": "^7.3.0", diff --git a/samples/nx-fastify-react/package.json b/samples/nx-fastify-react/package.json index 94a5fe3fd..35ab9eb0f 100644 --- a/samples/nx-fastify-react/package.json +++ b/samples/nx-fastify-react/package.json @@ -18,7 +18,7 @@ "fastify": "^3.21.0", "fastify-cors": "^6.0.2", "fastify-swagger": "^4.11.0", - "orval": "link:../../dist", + "orval": "link:../../../packages/orval/dist", "react": "17.0.2", "react-dom": "17.0.2", "react-query": "^3.34.19", diff --git a/samples/react-app-with-swr/package.json b/samples/react-app-with-swr/package.json index 0297cda8f..8a8ec6094 100644 --- a/samples/react-app-with-swr/package.json +++ b/samples/react-app-with-swr/package.json @@ -1,5 +1,5 @@ { - "name": "react-app", + "name": "swr", "version": "0.1.0", "private": true, "dependencies": { @@ -16,13 +16,13 @@ "typescript": "^4.1.3" }, "devDependencies": { - "orval": "link:../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject", - "generate-api": "node ../../dist/bin/orval.js" + "generate-api": "node ../../packages/orval/dist/bin/orval.js" }, "eslintConfig": { "extends": "react-app" diff --git a/samples/react-app/package.json b/samples/react-app/package.json index d52f4ed7e..aab64ca33 100644 --- a/samples/react-app/package.json +++ b/samples/react-app/package.json @@ -18,7 +18,7 @@ "start": "react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject", - "generate-api": "node ../../dist/bin/orval.js" + "generate-api": "node ../../packages/orval/dist/bin/orval.js" }, "eslintConfig": { "extends": "react-app" @@ -37,7 +37,7 @@ }, "devDependencies": { "msw": "^0.30.0", - "orval": "link:../../dist" + "orval": "link:../../../packages/orval/dist" }, "msw": { "workerDirectory": "public" diff --git a/samples/react-query/basic/package.json b/samples/react-query/basic/package.json index eb9117a4c..ac9d83cea 100644 --- a/samples/react-query/basic/package.json +++ b/samples/react-query/basic/package.json @@ -1,5 +1,5 @@ { - "name": "react-app", + "name": "react-query-basic", "version": "0.1.0", "private": true, "dependencies": { @@ -8,6 +8,7 @@ "@types/node": "^14.14.13", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "ajv": "^8.11.0", "axios": "^0.26.1", "msw": "^0.24.2", "react": "^17.0.1", @@ -16,13 +17,13 @@ "typescript": "^4.1.3" }, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject", - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "eslintConfig": { "extends": "react-app" diff --git a/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.msw.ts b/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.msw.ts index ee8c18165..5d7c5c683 100644 --- a/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.msw.ts +++ b/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.msw.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.ts b/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.ts index b7faf55ec..73ca92ba6 100644 --- a/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.ts +++ b/samples/react-query/basic/src/api/endpoints/petstoreFromFileSpecWithTransformer.ts @@ -1,10 +1,10 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 */ -import { useQuery, useInfiniteQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useInfiniteQuery, useMutation } from '@tanstack/react-quer'; import type { UseQueryOptions, UseInfiniteQueryOptions, @@ -14,7 +14,7 @@ import type { UseQueryResult, UseInfiniteQueryResult, QueryKey, -} from '@tanstack/react-query'; +} from '@tanstack/react-quer'; import type { Pets, Error, diff --git a/samples/react-query/basic/src/api/model/cat.ts b/samples/react-query/basic/src/api/model/cat.ts index b82d00f62..5752d551a 100644 --- a/samples/react-query/basic/src/api/model/cat.ts +++ b/samples/react-query/basic/src/api/model/cat.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/catType.ts b/samples/react-query/basic/src/api/model/catType.ts index 5d858194c..e3b3eff34 100644 --- a/samples/react-query/basic/src/api/model/catType.ts +++ b/samples/react-query/basic/src/api/model/catType.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/createPetsBody.ts b/samples/react-query/basic/src/api/model/createPetsBody.ts index 0753f994d..0288f6403 100644 --- a/samples/react-query/basic/src/api/model/createPetsBody.ts +++ b/samples/react-query/basic/src/api/model/createPetsBody.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/dachshund.ts b/samples/react-query/basic/src/api/model/dachshund.ts index 475dd1571..3353876df 100644 --- a/samples/react-query/basic/src/api/model/dachshund.ts +++ b/samples/react-query/basic/src/api/model/dachshund.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/dachshundBreed.ts b/samples/react-query/basic/src/api/model/dachshundBreed.ts index 36c56c56f..fd30936bc 100644 --- a/samples/react-query/basic/src/api/model/dachshundBreed.ts +++ b/samples/react-query/basic/src/api/model/dachshundBreed.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/dog.ts b/samples/react-query/basic/src/api/model/dog.ts index c02a9f035..b920553f5 100644 --- a/samples/react-query/basic/src/api/model/dog.ts +++ b/samples/react-query/basic/src/api/model/dog.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/dogType.ts b/samples/react-query/basic/src/api/model/dogType.ts index 011dc9dbb..b99c7d625 100644 --- a/samples/react-query/basic/src/api/model/dogType.ts +++ b/samples/react-query/basic/src/api/model/dogType.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/error.ts b/samples/react-query/basic/src/api/model/error.ts index 426c8d95d..181ab726f 100644 --- a/samples/react-query/basic/src/api/model/error.ts +++ b/samples/react-query/basic/src/api/model/error.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/labradoodle.ts b/samples/react-query/basic/src/api/model/labradoodle.ts index f0528b5a3..0326014d5 100644 --- a/samples/react-query/basic/src/api/model/labradoodle.ts +++ b/samples/react-query/basic/src/api/model/labradoodle.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/labradoodleBreed.ts b/samples/react-query/basic/src/api/model/labradoodleBreed.ts index 3f3f91e24..6a7ef1ec8 100644 --- a/samples/react-query/basic/src/api/model/labradoodleBreed.ts +++ b/samples/react-query/basic/src/api/model/labradoodleBreed.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/listPetsParams.ts b/samples/react-query/basic/src/api/model/listPetsParams.ts index 3d9da9eee..a2cf35f3e 100644 --- a/samples/react-query/basic/src/api/model/listPetsParams.ts +++ b/samples/react-query/basic/src/api/model/listPetsParams.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/pet.ts b/samples/react-query/basic/src/api/model/pet.ts index 0ed7a409e..9aee7aa3a 100644 --- a/samples/react-query/basic/src/api/model/pet.ts +++ b/samples/react-query/basic/src/api/model/pet.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/src/api/model/petCallingCode.ts b/samples/react-query/basic/src/api/model/petCallingCode.ts index f9de506c2..be9765cb4 100644 --- a/samples/react-query/basic/src/api/model/petCallingCode.ts +++ b/samples/react-query/basic/src/api/model/petCallingCode.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 @@ -9,6 +9,6 @@ export type PetCallingCode = typeof PetCallingCode[keyof typeof PetCallingCode]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PetCallingCode = { - '33': '+33', - '420': '+420', + NUMBER_PLUS_33: '+33', + NUMBER_PLUS_420: '+420', } as const; diff --git a/samples/react-query/basic/src/api/model/petCountry.ts b/samples/react-query/basic/src/api/model/petCountry.ts index 3a06e600b..435c053c0 100644 --- a/samples/react-query/basic/src/api/model/petCountry.ts +++ b/samples/react-query/basic/src/api/model/petCountry.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 @@ -9,6 +9,6 @@ export type PetCountry = typeof PetCountry[keyof typeof PetCountry]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const PetCountry = { - Peoples_Republic_of_China: "People's Republic of China", + "People's_Republic_of_China": "People's Republic of China", Uruguay: 'Uruguay', } as const; diff --git a/samples/react-query/basic/src/api/model/pets.ts b/samples/react-query/basic/src/api/model/pets.ts index f4c32651c..a29b176ce 100644 --- a/samples/react-query/basic/src/api/model/pets.ts +++ b/samples/react-query/basic/src/api/model/pets.ts @@ -1,5 +1,5 @@ /** - * Generated by orval v6.10.3 🍺 + * Generated by orval v6.11.0-alpha.1 🍺 * Do not edit manually. * Swagger Petstore * OpenAPI spec version: 1.0.0 diff --git a/samples/react-query/basic/yarn.lock b/samples/react-query/basic/yarn.lock index 6f09414de..8bd322c2a 100644 --- a/samples/react-query/basic/yarn.lock +++ b/samples/react-query/basic/yarn.lock @@ -2374,6 +2374,16 @@ ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" + integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -6905,6 +6915,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -7944,7 +7959,7 @@ original@^1.0.0: dependencies: url-parse "^1.4.3" -"orval@link:../../../dist": +"orval@link:../../../packages/orval/dist": version "0.0.0" uid "" @@ -9572,6 +9587,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" diff --git a/samples/react-query/custom-client/package.json b/samples/react-query/custom-client/package.json index b49bf6d89..c9aeea77f 100644 --- a/samples/react-query/custom-client/package.json +++ b/samples/react-query/custom-client/package.json @@ -1,5 +1,5 @@ { - "name": "react-app", + "name": "react-query-custom-client", "version": "0.1.0", "private": true, "dependencies": { @@ -18,13 +18,13 @@ "typescript": "^4.1.3" }, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "eject": "react-scripts eject", - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "eslintConfig": { "extends": "react-app" diff --git a/samples/react-query/form-data-mutator/package.json b/samples/react-query/form-data-mutator/package.json index 69529e518..8f5a8d5cb 100644 --- a/samples/react-query/form-data-mutator/package.json +++ b/samples/react-query/form-data-mutator/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "dependencies": { "@faker-js/faker": "^7.3.0", diff --git a/samples/react-query/form-data/package.json b/samples/react-query/form-data/package.json index b35e6cce6..9cc906b9e 100644 --- a/samples/react-query/form-data/package.json +++ b/samples/react-query/form-data/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "dependencies": { "@faker-js/faker": "^7.3.0", diff --git a/samples/react-query/form-url-encoded-mutator/package.json b/samples/react-query/form-url-encoded-mutator/package.json index e746dae36..fbd42d8f9 100644 --- a/samples/react-query/form-url-encoded-mutator/package.json +++ b/samples/react-query/form-url-encoded-mutator/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "dependencies": { "@faker-js/faker": "^7.3.0", diff --git a/samples/react-query/form-url-encoded/package.json b/samples/react-query/form-url-encoded/package.json index 195b998df..f7822e81b 100644 --- a/samples/react-query/form-url-encoded/package.json +++ b/samples/react-query/form-url-encoded/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "dependencies": { "@faker-js/faker": "^7.3.0", diff --git a/samples/react-query/hook-mutator/package.json b/samples/react-query/hook-mutator/package.json index 89edc98ef..c11cdf9fc 100644 --- a/samples/react-query/hook-mutator/package.json +++ b/samples/react-query/hook-mutator/package.json @@ -3,10 +3,10 @@ "version": "0.1.0", "private": true, "devDependencies": { - "orval": "link:../../../dist" + "orval": "link:../../../packages/orval/dist" }, "scripts": { - "generate-api": "node ../../../dist/bin/orval.js" + "generate-api": "node ../../../packages/orval/dist/bin/orval.js" }, "dependencies": { "@faker-js/faker": "^7.3.0", diff --git a/samples/svelte-query/package.json b/samples/svelte-query/package.json index d5dc0d3e5..290862650 100644 --- a/samples/svelte-query/package.json +++ b/samples/svelte-query/package.json @@ -7,7 +7,7 @@ "start": "svelte-kit start", "lint": "prettier --check . && eslint --ignore-path .gitignore .", "format": "prettier --write .", - "generate-api": "node ../../dist/bin/orval.js" + "generate-api": "node ../../packages/orval/dist/bin/orval.js" }, "devDependencies": { "@faker-js/faker": "^7.3.0", @@ -30,7 +30,7 @@ "dependencies": { "@sveltestack/svelte-query": "^1.6.0", "axios": "^0.26.1", - "orval": "link:../../dist", + "orval": "link:../../../packages/orval/dist", "ya": "^0.2.2" }, "type": "module", diff --git a/samples/vue-query/package.json b/samples/vue-query/package.json index af6d81c72..781c07983 100644 --- a/samples/vue-query/package.json +++ b/samples/vue-query/package.json @@ -1,10 +1,11 @@ { + "name": "vue-query", "version": "0.0.0", "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", "serve": "vite preview", - "generate-api": "node ../../dist/bin/orval.js" + "generate-api": "node ../../packages/orval/dist/bin/orval.js" }, "dependencies": { "@faker-js/faker": "^7.3.0", @@ -16,7 +17,7 @@ "@vitejs/plugin-vue": "^1.2.5", "@vue/compiler-sfc": "^3.0.5", "msw": "^0.32.0", - "orval": "link:../../dist", + "orval": "link:../../../packages/orval/dist", "typescript": "^4.3.2", "vite": "^2.4.2", "vue-tsc": "^0.0.24" diff --git a/src/core/getters/enum.ts b/src/core/getters/enum.ts index e584a8c08..017669159 100644 --- a/src/core/getters/enum.ts +++ b/src/core/getters/enum.ts @@ -29,10 +29,11 @@ export const getEnumImplementation = (value: string, type: string) => { } if (key.length > 1) { - key = sanitize(val, { - underscore: '_', + key = sanitize(key, { whitespace: '_', + underscore: true, dash: true, + special: true, }); } diff --git a/src/utils/file.ts b/src/utils/file.ts index f00fd6fd7..5e1cb1853 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -156,6 +156,7 @@ export async function loadFile( `Unexpected identifier`, ].join('|'), ); + // @ts-ignore if (!ignored.test(e.message)) { throw e; } diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..1ffe38974 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,23 @@ +{ + "exclude": ["node_modules", "dist", "__tests__", "**/*.test*", "**/*.spec*"], + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "es2015", + "module": "esnext", + "lib": ["es2017", "dom"], + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": false, + "moduleResolution": "node", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true + } +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 0890ddb55..000000000 --- a/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "exclude": ["node_modules", "dist", "__tests__", "**/*.test*", "**/*.spec*"], - "include": ["src/**/*"], - "compilerOptions": { - "skipLibCheck": true, - "target": "es5", - "module": "commonjs", - "lib": ["es2019", "dom", "es2016.array.include", "es2017.object"], - "declaration": true, - "declarationMap": true, - "downlevelIteration": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": false, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "inlineSourceMap": true, - "resolveJsonModule": true - } -} diff --git a/turbo.json b/turbo.json new file mode 100644 index 000000000..a4c018707 --- /dev/null +++ b/turbo.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://turbo.build/schema.json", + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false + }, + "lint": { + "outputs": [] + }, + "test": { + "outputs": [] + } + }, + "globalEnv": ["ORVAL_DEBUG_FILTER", "CI", "DEBUG"] +} diff --git a/types/esutils.d.ts b/types/esutils.d.ts new file mode 100644 index 000000000..ef2e551a1 --- /dev/null +++ b/types/esutils.d.ts @@ -0,0 +1,6 @@ +declare module 'esutils' { + export namespace keyword { + export function isIdentifierNameES5(str: string): boolean; + export function isKeywordES5(id: string, strict: boolean): boolean; + } +} diff --git a/types/ibm-openapi-validator.d.ts b/types/ibm-openapi-validator.d.ts new file mode 100644 index 000000000..7e0ee1115 --- /dev/null +++ b/types/ibm-openapi-validator.d.ts @@ -0,0 +1,24 @@ +declare module 'ibm-openapi-validator' { + interface OpenAPIError { + path: string; + message: string; + } + + interface ValidatorResults { + errors: OpenAPIError[]; + warnings: OpenAPIError[]; + } + + /** + * Returns a Promise with the validation results. + * + * @param openApiDoc An object that represents an OpenAPI document. + * @param defaultMode If set to true, the validator will ignore the .validaterc file and will use the configuration defaults. + */ + function validator( + openApiDoc: any, + defaultMode = false, + ): Promise; + + export default validator; +} diff --git a/types/swagger2openapi.d.ts b/types/swagger2openapi.d.ts new file mode 100644 index 000000000..d624fbfd4 --- /dev/null +++ b/types/swagger2openapi.d.ts @@ -0,0 +1,50 @@ +declare module 'swagger2openapi' { + import { OpenAPIObject } from 'openapi3-ts'; + interface ConverObjCallbackData { + openapi: OpenAPIObject; + } + + /** + * Source: https://github.com/Mermade/oas-kit/tree/master/packages/swagger2openapi#a-command-line + */ + interface Options { + /** mode to handle $ref's with sibling properties */ + refSiblings?: 'remove' | 'preserve' | 'allOf'; + /** resolve internal references also */ + resolveInternal?: boolean; + /** Property name to use for warning extensions [default: "x-s2o-warning"] */ + warnProperty?: string; + /** output information to unresolve a definition */ + components?: boolean; + /** enable debug mode, adds specification-extensions */ + debug?: boolean; + /** encoding for input/output files [default: "utf8"] */ + encoding?: string; + /** make resolution errors fatal */ + fatal?: boolean; + /** JSON indent to use, defaults to 4 spaces */ + indent?: string; + /** the output file to write to */ + outfile?: string; + /** fix up small errors in the source definition */ + patch?: boolean; + /** resolve external references */ + resolve?: boolean; + /** override default target version of 3.0.0 */ + targetVersion?: string; + /** url of original spec, creates x-origin entry */ + url?: string; + /** Do not throw on non-patchable errors, add warning extensions */ + warnOnly?: boolean; + /** write YAML, default JSON (overridden by outfile filepath extension */ + yaml?: boolean; + /** Extension to use to preserve body parameter names in converted operations ("" == disabled) [default: ""] */ + rbname?: string; + } + + function convertObj( + schema: unknown, + options: Options, + callback: (err: Error, data: ConverObjCallbackData) => void, + ): void; +} diff --git a/yarn.lock b/yarn.lock index f75152a48..5cee4426b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -889,11 +889,6 @@ "@types/node" "*" "@types/responselike" "*" -"@types/caseless@*": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" - integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== - "@types/chai-subset@^1.3.3": version "1.3.3" resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94" @@ -913,12 +908,12 @@ dependencies: chalk "*" -"@types/commander@^2.12.2": - version "2.12.2" - resolved "https://registry.yarnpkg.com/@types/commander/-/commander-2.12.2.tgz#183041a23842d4281478fa5d23c5ca78e6fd08ae" - integrity sha512-0QEFiR8ljcHp9bAbWxecjVRuAMr16ivPiGOw6KFQBVrVd0RQIcM3xKdRisH2EDWgVWujiYtHwhSkSUoAAGzH7Q== +"@types/debug@^4.1.7": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" + integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== dependencies: - commander "*" + "@types/ms" "*" "@types/es-aggregate-error@^1.0.2": version "1.0.2" @@ -1037,6 +1032,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== +"@types/ms@*": + version "0.7.31" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" + integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== + "@types/node@*", "@types/node@>=12", "@types/node@^18.6.3", "@types/node@^18.7.3": version "18.7.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.3.tgz#432c89796eab539b7a30b7b8801a727b585238a4" @@ -1062,16 +1062,6 @@ resolved "https://registry.yarnpkg.com/@types/ps-tree/-/ps-tree-1.1.2.tgz#5c60773a38ffb1402e049902a7b7a8d3c67cd59a" integrity sha512-ZREFYlpUmPQJ0esjxoG1fMvB2HNaD3z+mjqdSosZvd3RalncI9NEur73P8ZJz4YQdL64CmV1w0RuqoRUlhQRBw== -"@types/request@^2.48.8": - version "2.48.8" - resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.8.tgz#0b90fde3b655ab50976cb8c5ac00faca22f5a82c" - integrity sha512-whjk1EDJPcAR2kYHRbFl/lKeeKYTi05A15K9bnLInCVroNDCtXce57xKdI0/rQaA3K+6q0eFyUBPmqfSndUZdQ== - dependencies: - "@types/caseless" "*" - "@types/node" "*" - "@types/tough-cookie" "*" - form-data "^2.5.0" - "@types/responselike@*", "@types/responselike@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" @@ -1086,11 +1076,6 @@ dependencies: "@types/node" "*" -"@types/tough-cookie@*": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397" - integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== - "@types/urijs@^1.19.16": version "1.19.19" resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.19.tgz#2789369799907fc11e2bc6e3a00f6478c2281b95" @@ -1790,18 +1775,13 @@ colorette@^2.0.16, colorette@^2.0.17: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== -combined-stream@^1.0.6, combined-stream@^1.0.8: +combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== dependencies: delayed-stream "~1.0.0" -commander@*, commander@^9.3.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.0.tgz#bc4a40918fefe52e22450c111ecd6b7acce6f11c" - integrity sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw== - commander@^2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -1812,6 +1792,11 @@ commander@^4.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== +commander@^9.3.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.0.tgz#bc4a40918fefe52e22450c111ecd6b7acce6f11c" + integrity sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -2563,6 +2548,11 @@ esbuild-openbsd-64@0.15.3: resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.3.tgz#fa2e84480fe043480401c73fdfb944ba2d21826d" integrity sha512-QL7xYQ4noukuqh8UGnsrk1m+ShPMYIXjOnAQl3siA7VV6cjuUoCxx6cThgcUDzih8iL5u2xgsGRhsviQIMsUuA== +esbuild-plugin-alias@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz#45a86cb941e20e7c2bc68a2bea53562172494fcb" + integrity sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ== + esbuild-sunos-64@0.14.54: version "0.14.54" resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da" @@ -2699,6 +2689,13 @@ eslint-config-prettier@^8.5.0: resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz#5a81680ec934beca02c7b1a61cf8ca34b66feab1" integrity sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q== +eslint-config-turbo@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/eslint-config-turbo/-/eslint-config-turbo-0.0.4.tgz#4850255f8c858131843aa38854d4aed0ff09bb6e" + integrity sha512-HErPS/wfWkSdV9Yd2dDkhZt3W2B78Ih/aWPFfaHmCMjzPalh+5KxRRGTf8MOBQLCebcWJX0lP1Zvc1rZIHlXGg== + dependencies: + eslint-plugin-turbo "0.0.4" + eslint-plugin-prettier@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" @@ -2706,6 +2703,11 @@ eslint-plugin-prettier@^4.2.1: dependencies: prettier-linter-helpers "^1.0.0" +eslint-plugin-turbo@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-turbo/-/eslint-plugin-turbo-0.0.4.tgz#966f3dd6202143d0c28dc9cdbb48b0f779d06172" + integrity sha512-dfmYE/iPvoJInQq+5E/0mj140y/rYwKtzZkn3uVK8+nvwC5zmWKQ6ehMWrL4bYBkGzSgpOndZM+jOXhPQ2m8Cg== + eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -3067,15 +3069,6 @@ form-data@4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -form-data@^2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" - integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - format-util@^1.0.3: version "1.0.5" resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" @@ -6219,6 +6212,48 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" +turbo-darwin-64@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-1.6.3.tgz#fad7e078784b0fafc0b1f75ce9378828918595f5" + integrity sha512-QmDIX0Yh1wYQl0bUS0gGWwNxpJwrzZU2GIAYt3aOKoirWA2ecnyb3R6ludcS1znfNV2MfunP+l8E3ncxUHwtjA== + +turbo-darwin-arm64@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo-darwin-arm64/-/turbo-darwin-arm64-1.6.3.tgz#f0a32cae39e3fcd3da5e3129a94c18bb2e3ed6aa" + integrity sha512-75DXhFpwE7CinBbtxTxH08EcWrxYSPFow3NaeFwsG8aymkWXF+U2aukYHJA6I12n9/dGqf7yRXzkF0S/9UtdyQ== + +turbo-linux-64@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-1.6.3.tgz#8ddc6ac55ef84641182fe5ff50647f1b355826b0" + integrity sha512-O9uc6J0yoRPWdPg9THRQi69K6E2iZ98cRHNvus05lZbcPzZTxJYkYGb5iagCmCW/pq6fL4T4oLWAd6evg2LGQA== + +turbo-linux-arm64@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-1.6.3.tgz#846c1dc84d8dc741651906613c16acccba30428c" + integrity sha512-dCy667qqEtZIhulsRTe8hhWQNCJO0i20uHXv7KjLHuFZGCeMbWxB8rsneRoY+blf8+QNqGuXQJxak7ayjHLxiA== + +turbo-windows-64@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-1.6.3.tgz#89ac819fa76ad31d12fbfdeb3045bcebd0d308eb" + integrity sha512-lKRqwL3mrVF09b9KySSaOwetehmGknV9EcQTF7d2dxngGYYX1WXoQLjFP9YYH8ZV07oPm+RUOAKSCQuDuMNhiA== + +turbo-windows-arm64@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-1.6.3.tgz#977607c9a51f0b76076c8b158bafce06ce813070" + integrity sha512-BXY1sDPEA1DgPwuENvDCD8B7Hb0toscjus941WpL8CVd10hg9pk/MWn9CNgwDO5Q9ks0mw+liDv2EMnleEjeNA== + +turbo@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/turbo/-/turbo-1.6.3.tgz#ec26cc8907c38a9fd6eb072fb10dad254733543e" + integrity sha512-FtfhJLmEEtHveGxW4Ye/QuY85AnZ2ZNVgkTBswoap7UMHB1+oI4diHPNyqrQLG4K1UFtCkjOlVoLsllUh/9QRw== + optionalDependencies: + turbo-darwin-64 "1.6.3" + turbo-darwin-arm64 "1.6.3" + turbo-linux-64 "1.6.3" + turbo-linux-arm64 "1.6.3" + turbo-windows-64 "1.6.3" + turbo-windows-arm64 "1.6.3" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"