From ba102be99923aee4b7206698264ec6dc0bd2d8d5 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Thu, 20 Jun 2024 18:14:32 +0200 Subject: [PATCH 01/27] wip: add OpenApi 3 support --- biome.json | 55 ++ package.json | 1 + .../Swaggie.Swashbuckle.csproj | 2 +- src/cli.ts | 2 +- src/gen/js/createBarrel.ts | 4 +- src/gen/js/genOperations.spec.ts | 11 +- src/gen/js/genOperations.ts | 11 +- src/gen/templateManager.spec.ts | 6 +- src/gen/templateManager.ts | 4 +- src/gen/util.spec.ts | 33 +- src/gen/util.ts | 54 +- src/index.spec.ts | 26 +- src/index.ts | 77 +-- src/schema.ts | 41 ++ src/swagger/index.ts | 3 +- src/swagger/operations.spec.ts | 6 +- src/swagger/operations.ts | 3 +- src/swagger/swagger.ts | 142 ---- src/types.ts | 30 - .../documentLoader.spec.ts} | 34 +- src/utils/documentLoader.ts | 139 ++++ src/utils/index.ts | 2 + src/utils/utils.spec.ts | 63 ++ src/utils/utils.ts | 65 ++ test/ci-test.config.json | 2 +- test/petstore-v2.json | 637 ------------------ test/petstore-v3.json | 189 ++++++ test/petstore-v3.yml | 119 ++++ test/petstore.yml | 105 --- test/sample-config.json | 2 +- test/snapshots.spec.ts | 2 +- yarn.lock | 5 + 32 files changed, 769 insertions(+), 1106 deletions(-) create mode 100644 biome.json create mode 100644 src/schema.ts delete mode 100644 src/swagger/swagger.ts rename src/{swagger/swagger.spec.ts => utils/documentLoader.spec.ts} (53%) create mode 100644 src/utils/documentLoader.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/utils.spec.ts create mode 100644 src/utils/utils.ts delete mode 100644 test/petstore-v2.json create mode 100644 test/petstore-v3.json create mode 100644 test/petstore-v3.yml delete mode 100644 test/petstore.yml diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..9a8f5f0 --- /dev/null +++ b/biome.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80, + "lineEnding": "lf", + "ignore": [ + "**/node_modules/**", + "**/dist/**", + "**/.tmp/**" + ] + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "trailingCommas": "es5", + "semicolons": "always" + } + }, + "organizeImports": { + "enabled": true + }, + "files": { + "ignoreUnknown": true, + "include": [ + "./" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noUselessFragments": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "master", + "root": "src" + } +} diff --git a/package.json b/package.json index 57b2116..83192ef 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@types/sinon": "17.0.3", "chai": "4.4.1", "mocha": "10.4.0", + "openapi-types": "^12.1.3", "sinon": "18.0.0", "sucrase": "3.35.0", "typescript": "5.4.5" diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj index c59dace..7b6566b 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj @@ -17,6 +17,6 @@ - + diff --git a/src/cli.ts b/src/cli.ts index d5a889b..3139f70 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,7 +43,7 @@ program program.parse(process.argv); -const options = program.opts() as FullAppOptions; +const options = program.opts(); runCodeGenerator(options).then(complete, error); diff --git a/src/gen/js/createBarrel.ts b/src/gen/js/createBarrel.ts index df82ba7..8e1278c 100644 --- a/src/gen/js/createBarrel.ts +++ b/src/gen/js/createBarrel.ts @@ -15,8 +15,8 @@ export async function generateBarrelFile(clients: any[], clientOptions: ClientOp .filter((c) => c) .map((c) => ({ fileName: (clientOptions.servicePrefix || '') + c, - className: (clientOptions.servicePrefix || '') + c + 'Client', - camelCaseName: camel((clientOptions.servicePrefix || '') + c + 'Client'), + className: `${(clientOptions.servicePrefix || '') + c}Client`, + camelCaseName: camel(`${(clientOptions.servicePrefix || '') + c}Client`), })), }; diff --git a/src/gen/js/genOperations.spec.ts b/src/gen/js/genOperations.spec.ts index f872a03..210a50b 100644 --- a/src/gen/js/genOperations.spec.ts +++ b/src/gen/js/genOperations.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import { prepareOperations, fixDuplicateOperations, getOperationName } from './genOperations'; -import { ApiOperation } from '../../types'; +import type { ApiOperation } from '../../types'; import { orderBy } from '../util'; describe('prepareOperations', () => { @@ -183,7 +183,9 @@ describe('prepareOperations', () => { it('query model should be generated instead array of params', () => { const expectedQueryType = 'IGetPetByIdFromPetServiceQuery'; - const [res, queryDefs] = prepareOperations([op], { queryModels: true } as any); + const [res, queryDefs] = prepareOperations([op], { + queryModels: true, + } as any); expect(queryDefs[expectedQueryType]).to.be.ok; expect(queryDefs[expectedQueryType].type).to.be.equal('object'); @@ -403,7 +405,10 @@ describe('getOperationName', () => { { input: { opId: '', group: 'group' }, expected: '' }, { input: { opId: null, group: null }, expected: '' }, { input: { opId: '', group: '' }, expected: '' }, - { input: { opId: 'Test_GetPetStory', group: 'Test' }, expected: 'getPetStory' }, + { + input: { opId: 'Test_GetPetStory', group: 'Test' }, + expected: 'getPetStory', + }, ].forEach((el) => { it(`should handle ${JSON.stringify(el.input)}`, () => { const res = getOperationName(el.input.opId, el.input.group); diff --git a/src/gen/js/genOperations.ts b/src/gen/js/genOperations.ts index 9e0bbad..072c9bb 100644 --- a/src/gen/js/genOperations.ts +++ b/src/gen/js/genOperations.ts @@ -1,13 +1,7 @@ import { camel } from 'case'; import { getTSParamType } from './support'; -import { - groupOperationsByGroupName, - getBestResponse, - escapeReservedWords, - orderBy, - upperFirst, -} from '../util'; +import { groupOperationsByGroupName, getBestResponse, orderBy, upperFirst } from '../util'; import type { IServiceClient, IApiOperation, @@ -18,6 +12,7 @@ import type { import { generateBarrelFile } from './createBarrel'; import { renderFile } from '../templateManager'; import type { ApiSpec, ApiOperation, ClientOptions, ApiOperationParam } from '../../types'; +import { escapeReservedWords } from '../../utils'; const MAX_QUERY_PARAMS: number = 1; @@ -150,7 +145,7 @@ export function getOperationName(opId: string | null, group?: string | null) { return opId; } - return camel(opId.replace(group + '_', '')); + return camel(opId.replace(`${group}_`, '')); } function getHeaders(op: ApiOperation, options: ClientOptions): IOperationParam[] { diff --git a/src/gen/templateManager.spec.ts b/src/gen/templateManager.spec.ts index cea2425..6edd9d8 100644 --- a/src/gen/templateManager.spec.ts +++ b/src/gen/templateManager.spec.ts @@ -1,6 +1,6 @@ -import os from 'os'; -import fs from 'fs'; -import path from 'path'; +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; import { expect } from 'chai'; import { loadAllTemplateFiles, renderFile } from './templateManager'; diff --git a/src/gen/templateManager.ts b/src/gen/templateManager.ts index 895adc5..1dfbeb0 100644 --- a/src/gen/templateManager.ts +++ b/src/gen/templateManager.ts @@ -1,5 +1,5 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; import { Eta } from 'eta'; let engine: Eta; diff --git a/src/gen/util.spec.ts b/src/gen/util.spec.ts index f45d416..a856893 100644 --- a/src/gen/util.spec.ts +++ b/src/gen/util.spec.ts @@ -1,10 +1,5 @@ import { expect } from 'chai'; -import { - groupOperationsByGroupName, - escapeReservedWords, - getBestResponse, - prepareOutputFilename, -} from './util'; +import { groupOperationsByGroupName, getBestResponse, prepareOutputFilename } from './util'; describe('groupOperationsByGroupName', () => { it('handles null', async () => { @@ -164,32 +159,6 @@ describe('groupOperationsByGroupName', () => { }); }); -describe('escapeReservedWords', () => { - it('handles null', () => { - const res = escapeReservedWords(null); - - expect(res).to.be.equal(null); - }); - - it('handles empty string', () => { - const res = escapeReservedWords(''); - - expect(res).to.be.equal(''); - }); - - it('handles safe word', () => { - const res = escapeReservedWords('Burrito'); - - expect(res).to.be.equal('Burrito'); - }); - - it('handles reserved word', () => { - const res = escapeReservedWords('return'); - - expect(res).to.be.equal('_return'); - }); -}); - describe('prepareOutputFilename', () => { [ { given: null, expected: null }, diff --git a/src/gen/util.ts b/src/gen/util.ts index 19b1bef..d70a428 100644 --- a/src/gen/util.ts +++ b/src/gen/util.ts @@ -1,5 +1,5 @@ -import { type Stats, lstatSync, mkdir, writeFileSync as fsWriteFileSync } from 'fs'; -import { dirname } from 'path'; +import { type Stats, lstatSync, mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; +import { dirname } from 'node:path'; import type { ApiOperation, ApiOperationResponse } from '../types'; @@ -46,11 +46,10 @@ export function getBestResponse(op: ApiOperation): ApiOperationResponse { const NOT_FOUND = 100000; const lowestCode = op.responses.reduce((code, resp) => { const responseCode = Number.parseInt(resp.code, 10); - if (isNaN(responseCode) || responseCode >= code) { + if (Number.isNaN(responseCode) || responseCode >= code) { return code; - } else { - return responseCode; } + return responseCode; }, NOT_FOUND); return lowestCode === NOT_FOUND @@ -58,51 +57,6 @@ export function getBestResponse(op: ApiOperation): ApiOperationResponse { : op.responses.find((resp) => resp.code === lowestCode.toString()); } -const reservedWords = [ - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'debugger', - 'default', - 'delete', - 'do', - 'else', - 'export', - 'extends', - 'finally', - 'for', - 'function', - 'if', - 'import', - 'in', - 'instanceof', - 'new', - 'return', - 'super', - 'switch', - 'this', - 'throw', - 'try', - 'typeof', - 'var', - 'void', - 'while', - 'with', - 'yield', -]; - -export function escapeReservedWords(name: string | null): string { - let escapedName = name; - - if (reservedWords.indexOf(name) >= 0) { - escapedName = '_' + name; - } - return escapedName; -} - /** This method tries to fix potentially wrong out parameter given from commandline */ export function prepareOutputFilename(out: string | null): string { if (!out) { diff --git a/src/index.spec.ts b/src/index.spec.ts index 7f8766e..96ad827 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; -import fs from 'fs'; +import fs from 'node:fs'; import * as fetch from 'node-fetch'; import { Response } from 'node-fetch'; import sinon from 'sinon'; -import { runCodeGenerator, applyConfigFile, verifySpec } from './index'; +import { runCodeGenerator, applyConfigFile } from './index'; describe('runCodeGenerator', () => { afterEach(sinon.restore); @@ -71,7 +71,7 @@ describe('runCodeGenerator', () => { it('fails when --config provided and the JSON file is wrong', () => { const parameters = { - config: './test/petstore.yml', + config: './test/petstore-v3.yml', }; return runCodeGenerator(parameters as any).catch((e) => @@ -81,7 +81,7 @@ describe('runCodeGenerator', () => { it('works with proper --config provided', (done) => { const stub = sinon.stub(fetch, 'default'); - const response = fs.readFileSync(`${__dirname}/../test/petstore-v2.json`, { + const response = fs.readFileSync(`${__dirname}/../test/petstore-v3.json`, { encoding: 'utf-8', }); stub.returns(new Promise((resolve) => resolve(new Response(response)))); @@ -111,25 +111,11 @@ describe('runCodeGenerator', () => { const parameters = { config: './test/sample-config.json', baseUrl: 'https://wp.pl', - src: './test/petstore.yml', + src: './test/petstore-v3.yml', }; const conf = await applyConfigFile(parameters as any); expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://wp.pl'); - expect(conf.src).to.be.equal('./test/petstore.yml'); - }); - - it('fails when OpenAPI3 is provided', (done) => { - const res = verifySpec({ - openapi: '3.0.2', - paths: {}, - } as any); - - res - .then(() => {}) - .catch((e) => { - expect(e).to.contain('Spec does not look like'); - }) - .finally(() => done()); + expect(conf.src).to.be.equal('./test/petstore-v3.yml'); }); }); diff --git a/src/index.ts b/src/index.ts index 7bf40fa..fec75af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,65 +1,66 @@ -import fs from 'fs'; +import fs from 'node:fs'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; import genJsCode from './gen/js'; import { loadAllTemplateFiles } from './gen/templateManager'; -import { getOperations, resolveSpec } from './swagger'; -import type { ApiSpec, ClientOptions, FullAppOptions } from './types'; +import { getOperations } from './swagger'; +import type { ClientOptions, FullAppOptions } from './types'; +import { loadSpecDocument, verifyDocumentSpec } from './utils'; -/** Runs whole code generation process. @returns generated code */ -export function runCodeGenerator(options: FullAppOptions) { - return verifyOptions(options) - .then(applyConfigFile) - .then((opts) => - resolveSpec(opts.src, { ignoreRefType: '#/definitions/' }) - .then((spec) => verifySpec(spec)) - .then((spec) => gen(spec, opts)) - .then((code) => [code, opts] as CodeGenResult) - ); +/** + * Runs the whole code generation process. + * @returns `CodeGenResult` + **/ +export async function runCodeGenerator(options: FullAppOptions): Promise { + try { + verifyOptions(options); + const opts = await applyConfigFile(options); + const spec = await loadSpecDocument(opts.src); + const verifiedSpec = verifyDocumentSpec(spec); + const code = await gen(verifiedSpec, opts); + + return [code, opts]; + } catch (e) { + return Promise.reject(e); + } } function verifyOptions(options: FullAppOptions) { if (!options) { - return Promise.reject('Options were not provided'); + throw new Error('Options were not provided'); } if (!!options.config === !!options.src) { - return Promise.reject('You need to provide either --config or --src parameters'); + throw new Error('You need to provide either --config or --src parameters'); } - return Promise.resolve(options); -} - -/** Validates if the spec is correct and if is supported */ -export function verifySpec(spec: ApiSpec): Promise { - if (!spec || !spec.swagger) - return Promise.reject('Spec does not look like Swagger / OpenAPI 2! Open API 3 support is WIP'); - return Promise.resolve(spec); } -function gen(spec: ApiSpec, options: ClientOptions): Promise { +function gen(spec: OA3.Document, options: ClientOptions): Promise { loadAllTemplateFiles(options.template || 'axios'); const operations = getOperations(spec); return genJsCode(spec, operations, options); } -export function applyConfigFile(options: FullAppOptions): Promise { - return new Promise((resolve, reject) => { +export async function applyConfigFile(options: FullAppOptions): Promise { + try { if (!options.config) { - return resolve(options); + return options; } const configUrl = options.config; - return readFile(configUrl) - .then((contents) => { - const parsedConfig = JSON.parse(contents); - if (!parsedConfig || parsedConfig.length < 1) { - return reject('Could not correctly load config file. Is it a valid JSON file?'); - } - return resolve(Object.assign({}, parsedConfig, options)); - }) - .catch((ex) => - reject('Could not correctly load config file. It does not exist or you cannot access it') + const configContents = await readFile(configUrl); + const parsedConfig = JSON.parse(configContents); + if (!parsedConfig || parsedConfig.length < 1) { + throw new Error( + `Could not correctly parse config file from "${configUrl}". Is it a valid JSON file?` ); - }); + } + return { ...parsedConfig, ...options }; + } catch (e) { + return Promise.reject( + 'Could not correctly load config file. It does not exist or you cannot access it' + ); + } } function readFile(filePath: string): Promise { diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..3c03a54 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,41 @@ +import type { OpenAPIV3 as OA3 } from 'openapi-types'; +import { escapeReservedWords } from './utils'; +import { camel } from 'case'; + +const models: Model[] = []; + +export default function (spec: OA3.Document) { + function getAllModels() { + return spec.components.schemas; + } + + function handleGenericTypes(): Model[] { + return []; + } +} + +export function getParamName(name: string): string { + return escapeReservedWords( + name + .split('.') + .map((x) => camel(x)) + .join('_') + ); +} + +export function getOperationName(opId: string, group?: string) { + if (!opId) { + return ''; + } + if (!group) { + return opId; + } + + return camel(opId.replace(`${group}_`, '')); +} + +interface Model { + name: string; + identifier: string; + params: (OA3.SchemaObject | OA3.ReferenceObject)[]; +} diff --git a/src/swagger/index.ts b/src/swagger/index.ts index 687052b..c55b1e3 100644 --- a/src/swagger/index.ts +++ b/src/swagger/index.ts @@ -1,4 +1,3 @@ -import { resolveSpec } from './swagger'; import { getOperations } from './operations'; -export { resolveSpec, getOperations }; +export { getOperations }; diff --git a/src/swagger/operations.spec.ts b/src/swagger/operations.spec.ts index b71327e..033ff41 100644 --- a/src/swagger/operations.spec.ts +++ b/src/swagger/operations.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { resolveSpec } from './swagger'; import { getOperations } from './operations'; +import { loadSpecDocument } from '../utils/documentLoader'; describe('getPathOperation', () => { it('should handle empty operation list', () => { @@ -87,8 +87,8 @@ describe('getPathOperation', () => { }); it('should parse operations from spec [PetStore Example]', async () => { - const path = `${__dirname}/../../test/petstore.yml`; - const spec = await resolveSpec(path); + const path = `${__dirname}/../../test/petstore-v3.yml`; + const spec = await loadSpecDocument(path); const operations = getOperations(spec); expect(operations).to.be.ok; expect(operations.length).to.eq(3); diff --git a/src/swagger/operations.ts b/src/swagger/operations.ts index e93c2da..55daf2b 100644 --- a/src/swagger/operations.ts +++ b/src/swagger/operations.ts @@ -2,7 +2,6 @@ import type { ApiOperation, ApiOperationResponse, ApiOperationSecurity, - ApiSpec, HttpMethod, } from '../types'; @@ -92,7 +91,7 @@ function getPathOperation(method: HttpMethod, pathInfo, spec: ApiSpec): ApiOpera } function getOperationGroupName(op: any): string { - let name = op.tags && op.tags.length ? op.tags[0] : 'default'; + let name = op.tags?.length ? op.tags[0] : 'default'; name = name.replace(/[^$_a-z0-9]+/gi, ''); return name.replace(/^[0-9]+/m, ''); } diff --git a/src/swagger/swagger.ts b/src/swagger/swagger.ts deleted file mode 100644 index e8013c4..0000000 --- a/src/swagger/swagger.ts +++ /dev/null @@ -1,142 +0,0 @@ -import YAML from 'js-yaml'; -import fetch from 'node-fetch'; -import type { ApiSpec } from '../types'; - -export interface SpecOptions { - /** - * A base ref string to ignore when expanding ref dependencies e.g. '#/definitions/' - */ - ignoreRefType?: string; -} - -export function resolveSpec(src: string | object, options?: SpecOptions): Promise { - if (!options) { - options = {}; - } - - if (typeof src === 'string') { - return loadFile(src).then((spec) => formatSpec(spec, src, options)); - } - return Promise.resolve(formatSpec(src as ApiSpec, null, options)); -} - -function loadFile(src: string): Promise { - if (/^https?:\/\//im.test(src)) { - return loadFromUrl(src); - } - if (String(process) === '[object process]') { - return readLocalFile(src).then((contents) => parseFileContents(contents, src)); - } - - throw new Error(`Unable to load api at '${src}'`); -} - -function loadFromUrl(url: string) { - return fetch(url) - .then((resp) => resp.text()) - .then((contents) => parseFileContents(contents, url)); -} - -function readLocalFile(filePath: string): Promise { - return new Promise((res, rej) => - require('fs').readFile(filePath, 'utf8', (err, contents) => (err ? rej(err) : res(contents))) - ); -} - -function parseFileContents(contents: string, path: string): object { - return /.ya?ml$/i.test(path) ? YAML.load(contents) : JSON.parse(contents); -} - -function formatSpec(spec: ApiSpec, src?: string, options?: SpecOptions): ApiSpec { - if (!spec.basePath) { - spec.basePath = ''; - } else if (spec.basePath.endsWith('/')) { - spec.basePath = spec.basePath.slice(0, -1); - } - - if (src && /^https?:\/\//im.test(src)) { - const parts = src.split('/'); - if (!spec.host) { - spec.host = parts[2]; - } - if (!spec.schemes || !spec.schemes.length) { - spec.schemes = [parts[0].slice(0, -1)]; - } - } else { - if (!spec.host) { - spec.host = 'localhost'; - } - if (!spec.schemes || !spec.schemes.length) { - spec.schemes = ['http']; - } - } - - const s: any = spec; - if (!s.produces || !s.produces.length) { - s.accepts = ['application/json']; // give sensible default - } else { - s.accepts = s.produces; - } - - if (!s.consumes) { - s.contentTypes = []; - } else { - s.contentTypes = s.consumes; - } - - delete s.consumes; - delete s.produces; - - return expandRefs(spec, spec, options) as ApiSpec; -} - -/** - * Recursively expand internal references in the form `#/path/to/object`. - * - * @param {object} data the object to search for and update refs - * @param {object} lookup the object to clone refs from - * @param {regexp=} refMatch an optional regex to match specific refs to resolve - * @returns {object} the resolved data object - */ -export function expandRefs(data: any, lookup: object, options: SpecOptions): any { - if (!data) { - return data; - } - - if (Array.isArray(data)) { - return data.map((item) => expandRefs(item, lookup, options)); - } - if (typeof data === 'object') { - if (dataCache.has(data)) { - return data; - } - if (data.$ref && !(options.ignoreRefType && data.$ref.startsWith(options.ignoreRefType))) { - const resolved = expandRef(data.$ref, lookup); - delete data.$ref; - data = Object.assign({}, resolved, data); - } - dataCache.add(data); - - for (const name in data) { - data[name] = expandRefs(data[name], lookup, options); - } - } - return data; -} - -function expandRef(ref: string, lookup: object): any { - const parts = ref.split('/'); - if (parts.shift() !== '#' || !parts[0]) { - throw new Error(`Only support JSON Schema $refs in format '#/path/to/ref'`); - } - let value = lookup; - while (parts.length) { - value = value[parts.shift()]; - if (!value) { - throw new Error(`Invalid schema reference: ${ref}`); - } - } - return value; -} - -const dataCache = new Set(); diff --git a/src/types.ts b/src/types.ts index 59692e2..616c87b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,31 +22,6 @@ export interface FullAppOptions extends ClientOptions { config?: string; } -export interface ApiRequestData { - method: HttpMethod; - url: string; - headers: { [index: string]: string }; - body: any; -} - -export interface ApiInfo { - version: string; - title: string; -} - -export interface ApiSpec { - swagger: string; - info: ApiInfo; - host?: string; - basePath?: string; - schemes?: string[]; - securityDefinitions?: any; - paths: any; - definitions: any; - accepts: string[]; - contentTypes: string[]; -} - export type Template = 'axios' | 'fetch' | 'ng1' | 'ng2' | 'swr-axios' | 'xior'; export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch'; export type DateSupport = 'string' | 'Date'; // 'luxon', 'momentjs', etc @@ -129,8 +104,3 @@ export interface ApiOperationSecurity { id: string; scopes?: string[]; } - -export interface ApiRights { - query?: any; - headers?: any; -} diff --git a/src/swagger/swagger.spec.ts b/src/utils/documentLoader.spec.ts similarity index 53% rename from src/swagger/swagger.spec.ts rename to src/utils/documentLoader.spec.ts index ca59cf2..cf95a2a 100644 --- a/src/swagger/swagger.spec.ts +++ b/src/utils/documentLoader.spec.ts @@ -1,57 +1,47 @@ import { expect } from 'chai'; -import fs from 'fs'; +import fs from 'node:fs'; import * as fetch from 'node-fetch'; import { Response } from 'node-fetch'; import sinon from 'sinon'; -import { resolveSpec } from './swagger'; +import { loadSpecDocument } from './documentLoader'; // URLs are not used to fetch anything. We are faking responses through SinonJS -const petstore2 = { - json: 'http://petstore.swagger.io/v2/swagger.json', - yaml: 'http://petstore.swagger.io/v2/swagger.yaml', +const petstore3 = { + json: 'http://petstore.swagger.io/v3/swagger.json', + yaml: 'http://petstore.swagger.io/v3/swagger.yaml', }; -describe('resolveSpec', () => { +describe('loadSpecDocument', () => { afterEach(sinon.restore); it('should resolve a JSON spec from url', async () => { const stub = sinon.stub(fetch, 'default'); - const response = fs.readFileSync(`${__dirname}/../../test/petstore-v2.json`, { + const response = fs.readFileSync(`${__dirname}/../../test/petstore-v3.json`, { encoding: 'utf-8', }); stub.returns(new Promise((resolve) => resolve(new Response(response)))); - const spec = await resolveSpec(petstore2.json); + const spec = await loadSpecDocument(petstore3.json); expect(spec).to.be.ok; - expect(spec.host).to.be.equal('petstore.swagger.io'); - expect(spec.basePath).to.be.equal('/v2'); - expect(spec.securityDefinitions).to.be.ok; - expect(spec.definitions).to.be.ok; expect(spec.paths).to.be.ok; }); it('should resolve a YAML spec from url', async () => { const stub = sinon.stub(fetch, 'default'); - const response = fs.readFileSync(`${__dirname}/../../test/petstore.yml`, { + const response = fs.readFileSync(`${__dirname}/../../test/petstore-v3.yml`, { encoding: 'utf-8', }); stub.returns(new Promise((resolve) => resolve(new Response(response)))); - const spec = await resolveSpec(petstore2.yaml); + const spec = await loadSpecDocument(petstore3.yaml); expect(spec).to.be.ok; - expect(spec.host).to.be.equal('petstore.swagger.io'); - expect(spec.basePath).to.be.equal('/v1'); - expect(spec.definitions).to.be.ok; expect(spec.paths).to.be.ok; }); it('should resolve a YAML spec from local file', async () => { - const path = `${__dirname}/../../test/petstore.yml`; - const spec = await resolveSpec(path); + const path = `${__dirname}/../../test/petstore-v3.yml`; + const spec = await loadSpecDocument(path); expect(spec).to.be.ok; - expect(spec.host).to.be.equal('petstore.swagger.io'); - expect(spec.basePath).to.be.equal('/v1'); - expect(spec.definitions).to.be.ok; expect(spec.paths).to.be.ok; }); }); diff --git a/src/utils/documentLoader.ts b/src/utils/documentLoader.ts new file mode 100644 index 0000000..d13e4db --- /dev/null +++ b/src/utils/documentLoader.ts @@ -0,0 +1,139 @@ +import YAML from 'js-yaml'; +import fs from 'node:fs'; +import fetch from 'node-fetch'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; + +export interface SpecOptions { + /** + * A base ref string to ignore when expanding ref dependencies e.g. '#/definitions/' + */ + ignoreRefType?: string; +} + +export async function loadSpecDocument(src: string | object): Promise { + if (typeof src === 'string') { + return await loadFile(src); + } + return src as OA3.Document; +} + +function loadFile(src: string): Promise { + if (/^https?:\/\//im.test(src)) { + return loadFromUrl(src); + } + if (String(process) === '[object process]') { + return readLocalFile(src); + } + + throw new Error(`Unable to load api at '${src}'`); +} + +function loadFromUrl(url: string) { + return fetch(url) + .then((resp) => resp.text()) + .then((contents) => parseFileContents(contents, url)); +} + +function readLocalFile(filePath: string) { + return new Promise((res, rej) => + fs.readFile(filePath, 'utf8', (err, contents) => (err ? rej(err) : res(contents))) + ).then((contents: string) => parseFileContents(contents, filePath)); +} + +function parseFileContents(contents: string, path: string): object { + return /.ya?ml$/i.test(path) ? YAML.load(contents) : JSON.parse(contents); +} + +// function formatSpec(spec: OA3.Document, src?: string, options?: SpecOptions): OA3.Document { +// if (!spec.basePath) { +// spec.basePath = ''; +// } else if (spec.basePath.endsWith('/')) { +// spec.basePath = spec.basePath.slice(0, -1); +// } + +// if (src && /^https?:\/\//im.test(src)) { +// const parts = src.split('/'); +// if (!spec.host) { +// spec.host = parts[2]; +// } +// if (!spec.schemes || !spec.schemes.length) { +// spec.schemes = [parts[0].slice(0, -1)]; +// } +// } else { +// if (!spec.host) { +// spec.host = 'localhost'; +// } +// if (!spec.schemes || !spec.schemes.length) { +// spec.schemes = ['http']; +// } +// } + +// const s: any = spec; +// if (!s.produces || !s.produces.length) { +// s.accepts = ['application/json']; // give sensible default +// } else { +// s.accepts = s.produces; +// } + +// if (!s.consumes) { +// s.contentTypes = []; +// } else { +// s.contentTypes = s.consumes; +// } + +// delete s.consumes; +// delete s.produces; + +// return expandRefs(spec, spec, options) as ApiSpec; +// } + +// /** +// * Recursively expand internal references in the form `#/path/to/object`. +// * +// * @param {object} data the object to search for and update refs +// * @param {object} lookup the object to clone refs from +// * @param {regexp=} refMatch an optional regex to match specific refs to resolve +// * @returns {object} the resolved data object +// */ +// export function expandRefs(data: any, lookup: object, options: SpecOptions): any { +// if (!data) { +// return data; +// } + +// if (Array.isArray(data)) { +// return data.map((item) => expandRefs(item, lookup, options)); +// } +// if (typeof data === 'object') { +// if (dataCache.has(data)) { +// return data; +// } +// if (data.$ref && !(options.ignoreRefType && data.$ref.startsWith(options.ignoreRefType))) { +// const resolved = expandRef(data.$ref, lookup); +// delete data.$ref; +// data = Object.assign({}, resolved, data); +// } +// dataCache.add(data); + +// for (const name in data) { +// data[name] = expandRefs(data[name], lookup, options); +// } +// } +// return data; +// } + +// function expandRef(ref: string, lookup: object): any { +// const parts = ref.split('/'); +// if (parts.shift() !== '#' || !parts[0]) { +// throw new Error(`Only support JSON Schema $refs in format '#/path/to/ref'`); +// } +// let value = lookup; +// while (parts.length) { +// value = value[parts.shift()]; +// if (!value) { +// throw new Error(`Invalid schema reference: ${ref}`); +// } +// } +// return value; +// } + +// const dataCache = new Set(); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..bcba1fe --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './utils'; +export * from './documentLoader'; diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts new file mode 100644 index 0000000..44d12d6 --- /dev/null +++ b/src/utils/utils.spec.ts @@ -0,0 +1,63 @@ +import { expect } from 'chai'; +import { type VerifableDocument, escapeReservedWords, verifyDocumentSpec } from './utils'; + +describe('escapeReservedWords', () => { + it('handles null', () => { + const res = escapeReservedWords(null); + + expect(res).to.be.eql(null); + }); + + it('handles empty string', () => { + const res = escapeReservedWords(''); + + expect(res).to.be.eql(''); + }); + + it('handles safe word', () => { + const res = escapeReservedWords('Burrito'); + + expect(res).to.be.eql('Burrito'); + }); + + it('handles reserved word', () => { + const res = escapeReservedWords('return'); + + expect(res).to.be.eql('_return'); + }); +}); + +describe('verifyDocumentSpec', () => { + it('should accept OpenAPI 3', () => { + expect(() => { + const res = verifyDocumentSpec({ + openapi: '3.0.3', + info: { + title: 'test', + version: '1.0', + }, + paths: {}, + }); + + expect(res).to.be.ok; + }).to.not.throw(); + }); + + it('should reject Swagger document', () => { + expect(() => { + const res = verifyDocumentSpec({ + swagger: '2.0', + } as VerifableDocument); + + expect(res).to.not.be.ok; + }).to.throw('not supported'); + }); + + it('should reject empty document', () => { + expect(() => { + const res = verifyDocumentSpec(null as any); + + expect(res).to.not.be.ok; + }).to.throw('is empty'); + }); +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..f4413a8 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,65 @@ +import type { OpenAPIV3 as OA3 } from 'openapi-types'; + +const reservedWords = [ + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'export', + 'extends', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'new', + 'return', + 'super', + 'switch', + 'this', + 'throw', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'yield', +]; + +export function escapeReservedWords(name?: string | null): string { + let escapedName = name; + + if (reservedWords.indexOf(name) >= 0) { + escapedName = `_${name}`; + } + return escapedName; +} + +/** Validates if the spec document is correct and if is supported */ +export function verifyDocumentSpec(spec: VerifableDocument): OA3.Document { + if (!spec) { + throw new Error('Document is empty!'); + } + if (spec.swagger || !spec.openapi) { + throw new Error( + "Swagger is not supported anymore. Use swagger2openapi (if you can't change the spec to OpenAPI)." + ); + } + + return spec; +} + +export interface VerifableDocument extends OA3.Document { + swagger?: string; + openapi: string; +} diff --git a/test/ci-test.config.json b/test/ci-test.config.json index 0225d78..9cd0151 100644 --- a/test/ci-test.config.json +++ b/test/ci-test.config.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/yhnavein/swaggie/master/schema.json", "out": "./.tmp/ci-test/petstore.ts", - "src": "./test/petstore-v2.json", + "src": "./test/petstore-v3.json", "template": "axios", "preferAny": true, "queryModels": true, diff --git a/test/petstore-v2.json b/test/petstore-v2.json deleted file mode 100644 index 01b3c5a..0000000 --- a/test/petstore-v2.json +++ /dev/null @@ -1,637 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", - "version": "1.0.6", - "title": "Swagger Petstore", - "termsOfService": "http://swagger.io/terms/", - "contact": { "email": "apiteam@swagger.io" }, - "license": { "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" } - }, - "host": "petstore.swagger.io", - "basePath": "/v2", - "tags": [ - { - "name": "pet", - "description": "Everything about your Pets", - "externalDocs": { "description": "Find out more", "url": "http://swagger.io" } - }, - { "name": "store", "description": "Access to Petstore orders" }, - { - "name": "user", - "description": "Operations about user", - "externalDocs": { "description": "Find out more about our store", "url": "http://swagger.io" } - } - ], - "schemes": ["https", "http"], - "paths": { - "/pet/{petId}/uploadImage": { - "post": { - "tags": ["pet"], - "summary": "uploads an image", - "description": "", - "operationId": "uploadFile", - "consumes": ["multipart/form-data"], - "produces": ["application/json"], - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet to update", - "required": true, - "type": "integer", - "format": "int64" - }, - { - "name": "additionalMetadata", - "in": "formData", - "description": "Additional data to pass to server", - "required": false, - "type": "string" - }, - { - "name": "file", - "in": "formData", - "description": "file to upload", - "required": false, - "type": "file" - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "$ref": "#/definitions/ApiResponse" } - } - }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] - } - }, - "/pet": { - "post": { - "tags": ["pet"], - "summary": "Add a new pet to the store", - "description": "", - "operationId": "addPet", - "consumes": ["application/json", "application/xml"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "in": "body", - "name": "body", - "description": "Pet object that needs to be added to the store", - "required": true, - "schema": { "$ref": "#/definitions/Pet" } - } - ], - "responses": { "405": { "description": "Invalid input" } }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] - }, - "put": { - "tags": ["pet"], - "summary": "Update an existing pet", - "description": "", - "operationId": "updatePet", - "consumes": ["application/json", "application/xml"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "in": "body", - "name": "body", - "description": "Pet object that needs to be added to the store", - "required": true, - "schema": { "$ref": "#/definitions/Pet" } - } - ], - "responses": { - "400": { "description": "Invalid ID supplied" }, - "404": { "description": "Pet not found" }, - "405": { "description": "Validation exception" } - }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] - } - }, - "/pet/findByStatus": { - "get": { - "tags": ["pet"], - "summary": "Finds Pets by status", - "description": "Multiple status values can be provided with comma separated strings", - "operationId": "findPetsByStatus", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "status", - "in": "query", - "description": "Status values that need to be considered for filter", - "required": true, - "type": "array", - "items": { - "type": "string", - "enum": ["available", "pending", "sold"], - "default": "available" - }, - "collectionFormat": "multi" - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "type": "array", "items": { "$ref": "#/definitions/Pet" } } - }, - "400": { "description": "Invalid status value" } - }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] - } - }, - "/pet/findByTags": { - "get": { - "tags": ["pet"], - "summary": "Finds Pets by tags", - "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", - "operationId": "findPetsByTags", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "tags", - "in": "query", - "description": "Tags to filter by", - "required": true, - "type": "array", - "items": { "type": "string" }, - "collectionFormat": "multi" - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "type": "array", "items": { "$ref": "#/definitions/Pet" } } - }, - "400": { "description": "Invalid tag value" } - }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }], - "deprecated": true - } - }, - "/pet/{petId}": { - "get": { - "tags": ["pet"], - "summary": "Find pet by ID", - "description": "Returns a single pet", - "operationId": "getPetById", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet to return", - "required": true, - "type": "integer", - "format": "int64" - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "$ref": "#/definitions/Pet" } - }, - "400": { "description": "Invalid ID supplied" }, - "404": { "description": "Pet not found" } - }, - "security": [{ "api_key": [] }] - }, - "post": { - "tags": ["pet"], - "summary": "Updates a pet in the store with form data", - "description": "", - "operationId": "updatePetWithForm", - "consumes": ["application/x-www-form-urlencoded"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet that needs to be updated", - "required": true, - "type": "integer", - "format": "int64" - }, - { - "name": "name", - "in": "formData", - "description": "Updated name of the pet", - "required": false, - "type": "string" - }, - { - "name": "status", - "in": "formData", - "description": "Updated status of the pet", - "required": false, - "type": "string" - } - ], - "responses": { "405": { "description": "Invalid input" } }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] - }, - "delete": { - "tags": ["pet"], - "summary": "Deletes a pet", - "description": "", - "operationId": "deletePet", - "produces": ["application/json", "application/xml"], - "parameters": [ - { "name": "api_key", "in": "header", "required": false, "type": "string" }, - { - "name": "petId", - "in": "path", - "description": "Pet id to delete", - "required": true, - "type": "integer", - "format": "int64" - } - ], - "responses": { - "400": { "description": "Invalid ID supplied" }, - "404": { "description": "Pet not found" } - }, - "security": [{ "petstore_auth": ["write:pets", "read:pets"] }] - } - }, - "/store/order": { - "post": { - "tags": ["store"], - "summary": "Place an order for a pet", - "description": "", - "operationId": "placeOrder", - "consumes": ["application/json"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "in": "body", - "name": "body", - "description": "order placed for purchasing the pet", - "required": true, - "schema": { "$ref": "#/definitions/Order" } - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "$ref": "#/definitions/Order" } - }, - "400": { "description": "Invalid Order" } - } - } - }, - "/store/order/{orderId}": { - "get": { - "tags": ["store"], - "summary": "Find purchase order by ID", - "description": "For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions", - "operationId": "getOrderById", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "description": "ID of pet that needs to be fetched", - "required": true, - "type": "integer", - "maximum": 10, - "minimum": 1, - "format": "int64" - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "$ref": "#/definitions/Order" } - }, - "400": { "description": "Invalid ID supplied" }, - "404": { "description": "Order not found" } - } - }, - "delete": { - "tags": ["store"], - "summary": "Delete purchase order by ID", - "description": "For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors", - "operationId": "deleteOrder", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "orderId", - "in": "path", - "description": "ID of the order that needs to be deleted", - "required": true, - "type": "integer", - "minimum": 1, - "format": "int64" - } - ], - "responses": { - "400": { "description": "Invalid ID supplied" }, - "404": { "description": "Order not found" } - } - } - }, - "/store/inventory": { - "get": { - "tags": ["store"], - "summary": "Returns pet inventories by status", - "description": "Returns a map of status codes to quantities", - "operationId": "getInventory", - "produces": ["application/json"], - "parameters": [], - "responses": { - "200": { - "description": "successful operation", - "schema": { - "type": "object", - "additionalProperties": { "type": "integer", "format": "int32" } - } - } - }, - "security": [{ "api_key": [] }] - } - }, - "/user/createWithArray": { - "post": { - "tags": ["user"], - "summary": "Creates list of users with given input array", - "description": "", - "operationId": "createUsersWithArrayInput", - "consumes": ["application/json"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "in": "body", - "name": "body", - "description": "List of user object", - "required": true, - "schema": { "type": "array", "items": { "$ref": "#/definitions/User" } } - } - ], - "responses": { "default": { "description": "successful operation" } } - } - }, - "/user/createWithList": { - "post": { - "tags": ["user"], - "summary": "Creates list of users with given input array", - "description": "", - "operationId": "createUsersWithListInput", - "consumes": ["application/json"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "in": "body", - "name": "body", - "description": "List of user object", - "required": true, - "schema": { "type": "array", "items": { "$ref": "#/definitions/User" } } - } - ], - "responses": { "default": { "description": "successful operation" } } - } - }, - "/user/{username}": { - "get": { - "tags": ["user"], - "summary": "Get user by user name", - "description": "", - "operationId": "getUserByName", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "username", - "in": "path", - "description": "The name that needs to be fetched. Use user1 for testing. ", - "required": true, - "type": "string" - } - ], - "responses": { - "200": { - "description": "successful operation", - "schema": { "$ref": "#/definitions/User" } - }, - "400": { "description": "Invalid username supplied" }, - "404": { "description": "User not found" } - } - }, - "put": { - "tags": ["user"], - "summary": "Updated user", - "description": "This can only be done by the logged in user.", - "operationId": "updateUser", - "consumes": ["application/json"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "username", - "in": "path", - "description": "name that need to be updated", - "required": true, - "type": "string" - }, - { - "in": "body", - "name": "body", - "description": "Updated user object", - "required": true, - "schema": { "$ref": "#/definitions/User" } - } - ], - "responses": { - "400": { "description": "Invalid user supplied" }, - "404": { "description": "User not found" } - } - }, - "delete": { - "tags": ["user"], - "summary": "Delete user", - "description": "This can only be done by the logged in user.", - "operationId": "deleteUser", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "username", - "in": "path", - "description": "The name that needs to be deleted", - "required": true, - "type": "string" - } - ], - "responses": { - "400": { "description": "Invalid username supplied" }, - "404": { "description": "User not found" } - } - } - }, - "/user/login": { - "get": { - "tags": ["user"], - "summary": "Logs user into the system", - "description": "", - "operationId": "loginUser", - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "name": "username", - "in": "query", - "description": "The user name for login", - "required": true, - "type": "string" - }, - { - "name": "password", - "in": "query", - "description": "The password for login in clear text", - "required": true, - "type": "string" - } - ], - "responses": { - "200": { - "description": "successful operation", - "headers": { - "X-Expires-After": { - "type": "string", - "format": "date-time", - "description": "date in UTC when token expires" - }, - "X-Rate-Limit": { - "type": "integer", - "format": "int32", - "description": "calls per hour allowed by the user" - } - }, - "schema": { "type": "string" } - }, - "400": { "description": "Invalid username/password supplied" } - } - } - }, - "/user/logout": { - "get": { - "tags": ["user"], - "summary": "Logs out current logged in user session", - "description": "", - "operationId": "logoutUser", - "produces": ["application/json", "application/xml"], - "parameters": [], - "responses": { "default": { "description": "successful operation" } } - } - }, - "/user": { - "post": { - "tags": ["user"], - "summary": "Create user", - "description": "This can only be done by the logged in user.", - "operationId": "createUser", - "consumes": ["application/json"], - "produces": ["application/json", "application/xml"], - "parameters": [ - { - "in": "body", - "name": "body", - "description": "Created user object", - "required": true, - "schema": { "$ref": "#/definitions/User" } - } - ], - "responses": { "default": { "description": "successful operation" } } - } - } - }, - "securityDefinitions": { - "api_key": { "type": "apiKey", "name": "api_key", "in": "header" }, - "petstore_auth": { - "type": "oauth2", - "authorizationUrl": "https://petstore.swagger.io/oauth/authorize", - "flow": "implicit", - "scopes": { "read:pets": "read your pets", "write:pets": "modify pets in your account" } - } - }, - "definitions": { - "ApiResponse": { - "type": "object", - "properties": { - "code": { "type": "integer", "format": "int32" }, - "type": { "type": "string" }, - "message": { "type": "string" } - } - }, - "Category": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "name": { "type": "string" } - }, - "xml": { "name": "Category" } - }, - "Pet": { - "type": "object", - "required": ["name", "photoUrls"], - "properties": { - "id": { "type": "integer", "format": "int64" }, - "category": { "$ref": "#/definitions/Category" }, - "name": { "type": "string", "example": "doggie" }, - "photoUrls": { - "type": "array", - "xml": { "wrapped": true }, - "items": { "type": "string", "xml": { "name": "photoUrl" } } - }, - "tags": { - "type": "array", - "xml": { "wrapped": true }, - "items": { "xml": { "name": "tag" }, "$ref": "#/definitions/Tag" } - }, - "status": { - "type": "string", - "description": "pet status in the store", - "enum": ["available", "pending", "sold"] - } - }, - "xml": { "name": "Pet" } - }, - "Tag": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "name": { "type": "string" } - }, - "xml": { "name": "Tag" } - }, - "Order": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "petId": { "type": "integer", "format": "int64" }, - "quantity": { "type": "integer", "format": "int32" }, - "shipDate": { "type": "string", "format": "date-time" }, - "status": { - "type": "string", - "description": "Order Status", - "enum": ["placed", "approved", "delivered"] - }, - "complete": { "type": "boolean" } - }, - "xml": { "name": "Order" } - }, - "User": { - "type": "object", - "properties": { - "id": { "type": "integer", "format": "int64" }, - "username": { "type": "string" }, - "firstName": { "type": "string" }, - "lastName": { "type": "string" }, - "email": { "type": "string" }, - "password": { "type": "string" }, - "phone": { "type": "string" }, - "userStatus": { "type": "integer", "format": "int32", "description": "User Status" } - }, - "xml": { "name": "User" } - } - }, - "externalDocs": { "description": "Find out more about Swagger", "url": "http://swagger.io" } -} diff --git a/test/petstore-v3.json b/test/petstore-v3.json new file mode 100644 index 0000000..27156a2 --- /dev/null +++ b/test/petstore-v3.json @@ -0,0 +1,189 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": [ + "pets" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Null response" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Pets": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/petstore-v3.yml b/test/petstore-v3.yml new file mode 100644 index 0000000..b01683d --- /dev/null +++ b/test/petstore-v3.yml @@ -0,0 +1,119 @@ +openapi: '3.0.0' +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: http://petstore.swagger.io/v1 +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + maximum: 100 + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Pets' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/test/petstore.yml b/test/petstore.yml deleted file mode 100644 index 3b2eb1e..0000000 --- a/test/petstore.yml +++ /dev/null @@ -1,105 +0,0 @@ -swagger: "2.0" -info: - version: 1.0.0 - title: Swagger Petstore - license: - name: MIT -host: petstore.swagger.io -basePath: /v1 -schemes: - - http -consumes: - - application/json -produces: - - application/json -paths: - /pets: - get: - summary: List all pets - operationId: listPets - tags: - - pets - parameters: - - name: limit - in: query - description: How many items to return at one time (max 100) - required: false - type: integer - format: int32 - responses: - "200": - description: An paged array of pets - headers: - x-next: - type: string - description: A link to the next page of responses - schema: - $ref: '#/definitions/Pets' - default: - description: unexpected error - schema: - $ref: '#/definitions/Error' - security: - - auth: - - Catalog - - Profile - post: - summary: Create a pet - operationId: createPets - tags: - - pets - responses: - "201": - description: Null response - default: - description: unexpected error - schema: - $ref: '#/definitions/Error' - /pets/{petId}: - get: - summary: Info for a specific pet - operationId: showPetById - tags: - - pets - parameters: - - name: petId - in: path - required: true - description: The id of the pet to retrieve - type: string - responses: - "200": - description: Expected response to a valid request - schema: - $ref: '#/definitions/Pets' - default: - description: unexpected error - schema: - $ref: '#/definitions/Error' -definitions: - Pet: - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - tag: - type: string - Pets: - type: array - items: - $ref: '#/definitions/Pet' - Error: - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string \ No newline at end of file diff --git a/test/sample-config.json b/test/sample-config.json index 97fd9fa..e2f683d 100644 --- a/test/sample-config.json +++ b/test/sample-config.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/yhnavein/swaggie/master/schema.json", "out": "./.tmp/test1", - "src": "https://petstore.swagger.io/v2/swagger.json", + "src": "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json", "template": "axios", "baseUrl": "https://google.pl", "preferAny": true, diff --git a/test/snapshots.spec.ts b/test/snapshots.spec.ts index 0d8d5af..d761c0e 100644 --- a/test/snapshots.spec.ts +++ b/test/snapshots.spec.ts @@ -10,7 +10,7 @@ describe('petstore snapshots', () => { it(`should match existing ${template} snapshot`, async () => { const snapshotFile = `./test/snapshots/${template}.ts`; const parameters: FullAppOptions = { - src: './test/petstore-v2.json', + src: './test/petstore-v3.json', out: './.tmp/test/', template, }; diff --git a/yarn.lock b/yarn.lock index c5a47e6..7a28acb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -715,6 +715,11 @@ once@^1.3.0: dependencies: wrappy "1" +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" From f8751f83af52b84c8a5ace21da1514edb32361e3 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 26 Jun 2024 14:03:54 +0200 Subject: [PATCH 02/27] chore: upgrade sample .net projects --- .../Controllers/UserController.cs | 92 ++++++++-------- .../dotnetcore/nswag/Swaggie.Nswag/Program.cs | 23 ++-- .../dotnetcore/nswag/Swaggie.Nswag/Startup.cs | 103 +++++++++--------- .../nswag/Swaggie.Nswag/Swaggie.Nswag.csproj | 10 +- .../Controllers/UserController.cs | 93 ++++++++-------- .../Swaggie.Swashbuckle/Program.cs | 23 ++-- .../Swaggie.Swashbuckle/Startup.cs | 95 ++++++++-------- .../Swaggie.Swashbuckle.csproj | 10 +- 8 files changed, 228 insertions(+), 221 deletions(-) diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs b/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs index 5d5aeb0..1e33700 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs @@ -1,61 +1,65 @@ using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Swaggie.Nswag.Controllers +namespace Swaggie.Nswag.Controllers; + +[Route("user")] +public class UserController : Controller { - [Route("user")] - public class UserController : Controller + [HttpGet("")] + [Produces(typeof(IList))] + public IActionResult GetUsers([FromQuery] UserRole? role) { - [HttpGet("")] - [Produces(typeof(IList))] - public IActionResult GetUsers() + var allUsers = new[] { - var users = new[] + new UserViewModel { - new UserViewModel() - { - Name = "Ann Bobcat", Id = 1, Email = "ann.b@test.org", Role = UserRole.Admin - }, - new UserViewModel() - { - Name = "Bob Johnson", Id = 2, Email = "bob.j@test.org", Role = UserRole.User - } - }; - - return Ok(users); - } - - [HttpPost("")] - [ProducesResponseType(typeof(UserViewModel), StatusCodes.Status201Created)] - public IActionResult CreateUser([FromBody]UserViewModel user) - { - return Created("some-url", user); - } + Name = "Ann Bobcat", Id = 1, Email = "ann.b@test.org", Role = UserRole.Admin + }, + new UserViewModel + { + Name = "Bob Johnson", Id = 2, Email = "bob.j@test.org", Role = UserRole.User + } + }; - [HttpDelete("{id}")] - [Produces(typeof(void))] - public IActionResult DeleteUser([FromRoute]long id) - { - return NoContent(); - } + var users = allUsers + .Where(u => role == null || u.Role == role) + .ToList(); + + return Ok(users); } - public class UserViewModel + [HttpPost("")] + [ProducesResponseType(typeof(UserViewModel), StatusCodes.Status201Created)] + public IActionResult CreateUser([FromBody] UserViewModel user) { - public string Name { get; set; } - - public long Id { get; set; } - - public string Email { get; set; } - - public UserRole Role { get; set; } + return Created("some-url", user); } - public enum UserRole + [HttpDelete("{id}")] + [Produces(typeof(void))] + public IActionResult DeleteUser([FromRoute] long id) { - Admin = 0, - User = 1, - Guest = 2 + return NoContent(); } } + +public class UserViewModel +{ + public string Name { get; set; } + + public long Id { get; set; } + + public string Email { get; set; } + + public UserRole Role { get; set; } +} + +public enum UserRole +{ + Admin = 0, + User = 1, + Guest = 2 +} diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs b/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs index ec55688..4d3f234 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs @@ -1,20 +1,19 @@ using System.IO; using Microsoft.AspNetCore.Hosting; -namespace Swaggie.Nswag +namespace Swaggie.Nswag; + +public class Program { - public class Program + public static void Main(string[] args) { - public static void Main(string[] args) - { - var host = new WebHostBuilder() - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseStartup() - .UseUrls("http://127.0.0.1:12345") - .Build(); + var host = new WebHostBuilder() + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseUrls("http://127.0.0.1:12345") + .Build(); - host.Run(); - } + host.Run(); } } diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs b/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs index 7702137..4b1ee3d 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs @@ -1,7 +1,5 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -9,71 +7,74 @@ using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; -namespace Swaggie.Nswag +namespace Swaggie.Nswag; + +public class Startup { - public class Startup + private readonly bool _isProduction; + + public Startup(IWebHostEnvironment env) { - private readonly bool _isProduction; + _isProduction = env.IsProduction(); + } - public Startup(IWebHostEnvironment env) + // This method gets called by the runtime. Use this method to add services to the container + public void ConfigureServices(IServiceCollection services) + { + services.Configure(options => { options.AllowSynchronousIO = true; }); + JsonConvert.DefaultSettings = () => new JsonSerializerSettings { - _isProduction = env.IsProduction(); - } + // Automatically converts DotNetNames to jsFriendlyNames + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; - // This method gets called by the runtime. Use this method to add services to the container - public void ConfigureServices(IServiceCollection services) - { - services.Configure(options => { options.AllowSynchronousIO = true; }); - JsonConvert.DefaultSettings = () => new JsonSerializerSettings + services.AddControllers() + .AddNewtonsoftJson(x => { - // Automatically converts DotNetNames to jsFriendlyNames - ContractResolver = new CamelCasePropertyNamesContractResolver() - }; - - services.AddControllers() - .AddNewtonsoftJson(x => - { - // Ignores potential reference loop problems - x.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + // Ignores potential reference loop problems + x.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; - // Serializes dotnet enums to strings (you can remove it if you prefer numbers instead) - x.SerializerSettings.Converters.Add(new StringEnumConverter()); - }); + // Serializes dotnet enums to strings (you can remove it if you prefer numbers instead) + x.SerializerSettings.Converters.Add(new StringEnumConverter()); + }); - services.AddHttpContextAccessor(); + services.AddHttpContextAccessor(); - if (!_isProduction) + if (!_isProduction) + { + services.AddSwaggerDocument(c => { - services.AddSwaggerDocument(c => + // c.GenerateEnumMappingDescription = true; + c.PostProcess = document => { - c.GenerateEnumMappingDescription = true; - c.PostProcess = document => - { - document.Info.Version = "v1"; - document.Info.Title = "Sample Api"; - }; - }); - } + document.Info.Version = "v1"; + document.Info.Title = "Sample Api"; + }; + }); } + } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime) + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, + IHostApplicationLifetime appLifetime) + { + if (!_isProduction) { - if (!_isProduction) - { - app.UseDeveloperExceptionPage(); - } + app.UseDeveloperExceptionPage(); + } - app.UseRouting(); + app.UseRouting(); - if (!_isProduction) + if (!_isProduction) + { + app.UseOpenApi(); + app.UseSwaggerUi3(c => { - app.UseOpenApi(); - app.UseSwaggerUi3(c => { c.ServerUrl = "/api"; }); - } - - app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + c.Path = "/swagger"; + c.DocumentPath = "/swagger/v1/swagger.json"; + }); } + + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } } diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj b/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj index 6a478ae..15f10a3 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj @@ -2,15 +2,15 @@ Exe - net6.0 + net8.0 warnings 10 - - - - + + + + diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs index 0f50b39..fc32d91 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs @@ -1,61 +1,66 @@ using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace Swaggie.Swashbuckle.Controllers +namespace Swaggie.Swashbuckle.Controllers; + +[Route("user")] +public class UserController : Controller { - [Route("user")] - public class UserController : Controller + [HttpGet("")] + [Produces(typeof(IList))] + public IActionResult GetUsers([FromQuery] UserRole? role) { - [HttpGet("")] - [Produces(typeof(IList))] - public IActionResult GetUsers() + var allUsers = new[] { - var users = new[] + new UserViewModel { - new UserViewModel() - { - Name = "Ann Bobcat", Id = 1, Email = "ann.b@test.org", Role = UserRole.Admin - }, - new UserViewModel() - { - Name = "Bob Johnson", Id = 2, Email = "bob.j@test.org", Role = UserRole.User - } - }; - - return Ok(users); - } - - [HttpPost("")] - [ProducesResponseType(typeof(UserViewModel), StatusCodes.Status201Created)] - public IActionResult CreateUser([FromBody]UserViewModel user) - { - return Created("some-url", user); - } + Name = "Ann Bobcat", Id = 1, Email = "ann.b@test.org", Role = UserRole.Admin + }, + new UserViewModel + { + Name = "Bob Johnson", Id = 2, Email = "bob.j@test.org", Role = UserRole.User + } + }; - [HttpDelete("{id}")] - [Produces(typeof(void))] - public IActionResult DeleteUser([FromRoute]long id) - { - return NoContent(); - } + var users = allUsers + .Where(u => role == null || u.Role == role) + .ToList(); + + return Ok(users); } - public class UserViewModel + [HttpPost("")] + [ProducesResponseType(typeof(UserViewModel), StatusCodes.Status201Created)] + public IActionResult CreateUser([FromBody] UserViewModel user) { - public string Name { get; set; } - - public long Id { get; set; } - - public string Email { get; set; } - - public UserRole Role { get; set; } + return Created("some-url", user); } - public enum UserRole + [HttpDelete("{id}")] + [Produces(typeof(void))] + public IActionResult DeleteUser([FromRoute] long id) { - Admin = 0, - User = 1, - Guest = 2 + return NoContent(); } } + +public class UserViewModel +{ + public string Name { get; set; } + + public long Id { get; set; } + + public string Email { get; set; } + + public UserRole Role { get; set; } +} + +public enum UserRole +{ + Admin = 0, + User = 1, + Guest = 2 +} + diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Program.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Program.cs index f093c39..a2270a1 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Program.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Program.cs @@ -1,20 +1,19 @@ using System.IO; using Microsoft.AspNetCore.Hosting; -namespace Swaggie.Swashbuckle +namespace Swaggie.Swashbuckle; + +public static class Program { - public class Program + public static void Main(string[] args) { - public static void Main(string[] args) - { - var host = new WebHostBuilder() - .UseKestrel() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseStartup() - .UseUrls("http://127.0.0.1:12345") - .Build(); + var host = new WebHostBuilder() + .UseKestrel() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseUrls("http://127.0.0.1:12345") + .Build(); - host.Run(); - } + host.Run(); } } diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs index 33d1081..a334d02 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs @@ -9,69 +9,68 @@ using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; -namespace Swaggie.Swashbuckle +namespace Swaggie.Swashbuckle; + +public class Startup { - public class Startup + private readonly bool _isProduction; + + public Startup(IWebHostEnvironment env) { - private readonly bool _isProduction; + _isProduction = env.IsProduction(); + } - public Startup(IWebHostEnvironment env) + // This method gets called by the runtime. Use this method to add services to the container + public void ConfigureServices(IServiceCollection services) + { + services.Configure(options => { options.AllowSynchronousIO = true; }); + JsonConvert.DefaultSettings = () => new JsonSerializerSettings { - _isProduction = env.IsProduction(); - } + // Automatically converts DotNetNames to jsFriendlyNames + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; - // This method gets called by the runtime. Use this method to add services to the container - public void ConfigureServices(IServiceCollection services) - { - services.Configure(options => { options.AllowSynchronousIO = true; }); - JsonConvert.DefaultSettings = () => new JsonSerializerSettings + services.AddControllers() + .AddNewtonsoftJson(x => { - // Automatically converts DotNetNames to jsFriendlyNames - ContractResolver = new CamelCasePropertyNamesContractResolver() - }; + // Ignores potential reference loop problems + x.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; - services.AddControllers() - .AddNewtonsoftJson(x => - { - // Ignores potential reference loop problems - x.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + // Serializes dotnet enums to strings (you can remove it if you prefer numbers instead) + x.SerializerSettings.Converters.Add(new StringEnumConverter()); + }); - // Serializes dotnet enums to strings (you can remove it if you prefer numbers instead) - x.SerializerSettings.Converters.Add(new StringEnumConverter()); - }); + services.AddHttpContextAccessor(); - services.AddHttpContextAccessor(); - - if (!_isProduction) + if (!_isProduction) + { + services.AddSwaggerGen(c => { - services.AddSwaggerGen(c => - { - c.CustomOperationIds(e => - $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); - c.SwaggerDoc("v1", new OpenApiInfo { Title = "Sample Api", Version = "v1" }); - }); - services.AddSwaggerGenNewtonsoftSupport(); - } + c.CustomOperationIds(e => + $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Sample Api", Version = "v1" }); + }); + services.AddSwaggerGenNewtonsoftSupport(); } + } - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, - IHostApplicationLifetime appLifetime) + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, + IHostApplicationLifetime appLifetime) + { + if (!_isProduction) { - if (!_isProduction) - { - app.UseDeveloperExceptionPage(); - } - - app.UseRouting(); + app.UseDeveloperExceptionPage(); + } - if (!_isProduction) - { - app.UseSwagger(c => c.SerializeAsV2 = true); - app.UseSwaggerUI(c => { c.SwaggerEndpoint("v1/swagger.json", "Sample Api"); }); - } + app.UseRouting(); - app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + if (!_isProduction) + { + app.UseSwagger(c => c.SerializeAsV2 = true); + app.UseSwaggerUI(c => { c.SwaggerEndpoint("v1/swagger.json", "Sample Api"); }); } + + app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } } diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj index 7b6566b..3bc58df 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Swaggie.Swashbuckle.csproj @@ -2,17 +2,17 @@ Exe - net6.0 + net8.0 warnings 10 - - + + - - + + From 39c3ff10f0a3fa1ec330c366cb1a0bd47413389e Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 26 Jun 2024 22:53:49 +0200 Subject: [PATCH 03/27] feat: workout the parameters handling in OA3 --- src/gen/js/genOperations.ts | 6 +- src/gen/js/genTypes.ts | 4 +- .../js/serializeQueryParam.angular1.spec.ts | 8 +- src/gen/js/serializeQueryParam.spec.ts | 18 +- src/gen/js/support.spec.ts | 207 ++++++------------ src/gen/js/support.ts | 86 ++++---- src/gen/templateManager.spec.ts | 6 +- src/gen/util.spec.ts | 105 ++++++--- src/gen/util.ts | 40 ++-- src/index.spec.ts | 44 ++-- src/types.ts | 15 -- wallaby.js | 12 + 12 files changed, 281 insertions(+), 270 deletions(-) create mode 100644 wallaby.js diff --git a/src/gen/js/genOperations.ts b/src/gen/js/genOperations.ts index 072c9bb..af54fe8 100644 --- a/src/gen/js/genOperations.ts +++ b/src/gen/js/genOperations.ts @@ -1,6 +1,6 @@ import { camel } from 'case'; -import { getTSParamType } from './support'; +import { getParameterType } from './support'; import { groupOperationsByGroupName, getBestResponse, orderBy, upperFirst } from '../util'; import type { IServiceClient, @@ -82,7 +82,7 @@ export function prepareOperations( return [ ops.map((op) => { const response = getBestResponse(op); - const respType = getTSParamType(response, options); + const respType = getParameterType(response, options); let queryParams = getParams(op.parameters, options, ['query']); let params = getParams(op.parameters, options); @@ -184,7 +184,7 @@ function getParams( .map((p) => ({ originalName: p.name, name: getParamName(p.name), - type: getTSParamType(p, options), + type: getParameterType(p, options), optional: p.required === undefined || p.required === null ? p['x-nullable'] === undefined || p['x-nullable'] === null diff --git a/src/gen/js/genTypes.ts b/src/gen/js/genTypes.ts index 7a34679..5a825af 100644 --- a/src/gen/js/genTypes.ts +++ b/src/gen/js/genTypes.ts @@ -1,6 +1,6 @@ import { dset as set } from 'dset'; import { join, uniq } from '../util'; -import { getTSParamType } from './support'; +import { getParameterType } from './support'; import type { IQueryDefinitions } from './models'; import type { ApiSpec, ClientOptions } from '../../types'; @@ -192,7 +192,7 @@ function renderTsTypeProp( typeToBeGeneric?: string ): string[] { const lines = []; - let type = getTSParamType(info, options); + let type = getParameterType(info, options); if (typeToBeGeneric && type.indexOf(typeToBeGeneric) === 0) { type = type.replace(typeToBeGeneric, 'T'); } diff --git a/src/gen/js/serializeQueryParam.angular1.spec.ts b/src/gen/js/serializeQueryParam.angular1.spec.ts index b670a63..636b4df 100644 --- a/src/gen/js/serializeQueryParam.angular1.spec.ts +++ b/src/gen/js/serializeQueryParam.angular1.spec.ts @@ -27,7 +27,7 @@ function serializeQueryParam(obj: any, property: string): string { } describe('serializeQueryParam.angular1', () => { - [ + const testCases = [ { input: '', property: 'page', expected: '' }, { input: null, property: 'page', expected: '' }, { input: undefined, property: 'page', expected: '' }, @@ -51,11 +51,13 @@ describe('serializeQueryParam.angular1', () => { property: 'filter', expected: 'filter.name=John&filter.agentId=7', }, - ].forEach((el) => { + ]; + + for (const el of testCases) { it(`should handle ${JSON.stringify(el.input)} with property ${el.property}`, () => { const res = serializeQueryParam(el.input, el.property); expect(res).to.be.equal(el.expected); }); - }); + } }); diff --git a/src/gen/js/serializeQueryParam.spec.ts b/src/gen/js/serializeQueryParam.spec.ts index aa6f76d..c64d84c 100644 --- a/src/gen/js/serializeQueryParam.spec.ts +++ b/src/gen/js/serializeQueryParam.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; /** This file tests a code that will be generated and is hardcoded into the templates */ describe('serializeQueryParam', () => { - [ + const testCases = [ { input: '', expected: '' }, { input: null, expected: '' }, { input: undefined, expected: '' }, @@ -13,13 +13,15 @@ describe('serializeQueryParam', () => { { input: { a: 1, b: [1, 2, 3, 'test'] }, expected: 'a=1&b=1%2C2%2C3%2Ctest' }, { input: [1, 2, 3], expected: '1%2C2%2C3' }, { input: new Date('2020-04-16T00:00:00.000Z'), expected: '2020-04-16T00%3A00%3A00.000Z' }, - ].forEach((el) => { + ]; + + for (const el of testCases) { it(`should handle ${JSON.stringify(el.input)}`, () => { const res = serializeQueryParam(el.input); expect(res).to.be.equal(el.expected); }); - }); + } function serializeQueryParam(obj: any) { if (obj === null || obj === undefined) return ''; @@ -27,7 +29,7 @@ describe('serializeQueryParam', () => { if (typeof obj !== 'object' || Array.isArray(obj)) return encodeURIComponent(obj); return Object.keys(obj) .reduce( - (a, b) => a.push(encodeURIComponent(b) + '=' + encodeURIComponent(obj[b])) && a, + (a, b) => a.push(`${encodeURIComponent(b)}=${encodeURIComponent(obj[b])}`) && a, [] ) .join('&'); @@ -36,7 +38,7 @@ describe('serializeQueryParam', () => { /** This is different, because we don't need to encode parameters for axios as axios is doing it on its own */ describe('serializeQueryParam / axios', () => { - [ + for (const el of [ { input: '', expected: '' }, { input: null, expected: '' }, { input: undefined, expected: '' }, @@ -46,13 +48,13 @@ describe('serializeQueryParam / axios', () => { { input: { a: 1, b: 'test' }, expected: 'a=1&b=test' }, { input: { a: 1, b: [1, 2, 3, 'test'] }, expected: 'a=1&b=1,2,3,test' }, { input: new Date('2020-04-16T00:00:00.000Z'), expected: '2020-04-16T00:00:00.000Z' }, - ].forEach((el) => { + ]) { it(`should handle ${JSON.stringify(el.input)}`, () => { const res = serializeQueryParam(el.input); expect(res).to.eq(el.expected); }); - }); + } it('should handle array', () => { const res = serializeQueryParam([1, 2, 3]); @@ -65,7 +67,7 @@ describe('serializeQueryParam / axios', () => { if (obj instanceof Date) return obj.toJSON(); if (typeof obj !== 'object' || Array.isArray(obj)) return obj; return Object.keys(obj) - .reduce((a, b) => a.push(b + '=' + obj[b]) && a, []) + .reduce((a, b) => a.push(`${b}=${obj[b]}`) && a, []) .join('&'); } }); diff --git a/src/gen/js/support.spec.ts b/src/gen/js/support.spec.ts index 7fa79c7..41e099d 100644 --- a/src/gen/js/support.spec.ts +++ b/src/gen/js/support.spec.ts @@ -1,106 +1,68 @@ import { expect } from 'chai'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; import type { ClientOptions } from '../../types'; -import { getTSParamType } from './support'; - -describe('getTSParamType', () => { - it('empty #1', async () => { - const param = null; - const options = { - preferAny: true, - } as any; - - const res = getTSParamType(param, options); - - expect(res).to.be.equal('any'); - }); - - it('empty #2', async () => { - const param = null; - const options = {} as any; - - const res = getTSParamType(param, options); - - expect(res).to.be.equal('unknown'); - }); - - it('empty #3', async () => { - const param = null; - const options = {} as any; - - const res = getTSParamType(param, options); - - expect(res).to.be.equal('unknown'); - }); - - it('empty #3', async () => { - const param = []; - const options = {} as any; +import { getParameterType } from './support'; + +describe('getParameterType', () => { + describe('empty cases', () => { + const testCases = [ + { param: null, options: { preferAny: true }, expected: 'any' }, + { param: undefined, options: {}, expected: 'unknown' }, + { param: {}, options: {}, expected: 'unknown' }, + { param: [], options: {}, expected: 'unknown' }, + { param: [], options: { preferAny: true }, expected: 'any' }, + { + param: { name: 'a', in: 'query' } as OA3.ParameterObject, + options: {}, + expected: 'unknown', + }, + ]; - const res = getTSParamType(param, options); + for (const { param, options, expected } of testCases) { + it(`should process ${param} correctly`, async () => { + const res = getParameterType(param as any, options); - expect(res).to.be.equal('unknown'); + expect(res).to.be.equal(expected); + }); + } }); it('file', async () => { - const param = { + const param: OA3.ParameterObject = { name: 'attachment', in: 'body', required: false, - type: 'file', + schema: { + type: 'string', + format: 'binary', + }, }; - const options = {} as any; + const options = {}; - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('File'); }); - it('enum with x-schema', async () => { - const param = { - type: 'integer', - name: 'SomeEnum', + it('array with a reference type', async () => { + const param: OA3.ParameterObject = { + name: 'items', in: 'query', - 'x-schema': { - $ref: '#/definitions/SomeEnum', - }, - 'x-nullable': false, - enum: [1, 2], - }; - const options = {} as any; - - const res = getTSParamType(param, options); - - expect(res).to.be.equal('SomeEnum'); - }); - - it('array', async () => { - const param = { - uniqueItems: false, - type: 'array', - items: { - $ref: '#/definitions/Item', + schema: { + type: 'array', + items: { + $ref: '#/definitions/Item', + }, }, }; - const options = {} as any; - const res = getTSParamType(param, options); + const res = getParameterType(param, {}); expect(res).to.be.equal('Item[]'); }); - it('reference #0', async () => { - const param = { - $ref: '#/definitions/SomeItem', - }; - const options = {} as any; - - const res = getTSParamType(param, options); - - expect(res).to.be.equal('SomeItem'); - }); - it('reference #1', async () => { - const param = { + const param: OA3.ParameterObject = { name: 'something', in: 'body', required: false, @@ -108,32 +70,16 @@ describe('getTSParamType', () => { $ref: '#/definitions/SomeItem', }, }; - const options = {} as any; + const options = {}; - const res = getTSParamType(param, options); - - expect(res).to.be.equal('SomeItem'); - }); - - it('reference #2', async () => { - const param = { - name: 'something', - in: 'body', - required: false, - schema: { - $ref: '#/definitions/SomeItem', - }, - }; - const options = {} as any; - - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('SomeItem'); }); describe('responses', () => { it('generics', async () => { - const param = { + const param: OA3.ParameterObject = { name: 'query', in: 'body', required: false, @@ -141,92 +87,77 @@ describe('getTSParamType', () => { $ref: '#/definitions/PagingAndSortingParameters[Item]', }, }; - const options = {} as any; + const options = {}; - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('PagingAndSortingParameters'); }); it('string', async () => { - const param = { + const param: OA3.ParameterObject = { name: 'title', in: 'query', required: false, - type: 'string', + schema: { + type: 'string', + }, }; - const options = {} as any; + const options = {}; - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('string'); }); it('date', async () => { - const param = { + const param: OA3.ParameterObject = { name: 'dateFrom', in: 'query', required: false, - type: 'string', - format: 'date-time', + schema: { + type: 'string', + format: 'date-time', + }, }; - const options = {} as any; + const options = {}; - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('Date'); }); it('date with dateFormatter = string', async () => { - const param = { + const param: OA3.ParameterObject = { name: 'dateFrom', in: 'query', required: false, - type: 'string', - format: 'date-time', + schema: { + type: 'string', + format: 'date-time', + }, }; const options = { dateFormat: 'string' } as ClientOptions; - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('string'); }); - // Full enums are not implemented. This is to ensure that full enums won't break anything - it('enum', async () => { - const param = { - name: 'documentType', - in: 'path', - required: true, - type: 'integer', - format: 'int32', - enum: ['Active', 'Disabled'], - fullEnum: { - Active: 0, - Disabled: 1, - }, - }; - const options = {} as any; - - const res = getTSParamType(param, options); - - expect(res).to.be.equal('number'); - }); - it('array > reference', async () => { - const param = { - description: 'Success', + const param: OA3.ParameterObject = { + name: 'items', + in: 'query', schema: { - uniqueItems: false, type: 'array', items: { $ref: '#/definitions/Item', }, }, }; - const options = {} as any; + const options = {}; - const res = getTSParamType(param, options); + const res = getParameterType(param, options); expect(res).to.be.equal('Item[]'); }); diff --git a/src/gen/js/support.ts b/src/gen/js/support.ts index 061ed7f..d53ebf6 100644 --- a/src/gen/js/support.ts +++ b/src/gen/js/support.ts @@ -1,62 +1,70 @@ import type { ClientOptions } from '../../types'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; -export function getTSParamType(param: any, options: ClientOptions): string { +/** + * Converts a parameter object to a TypeScript type. + * @example + * { + * name: 'title', + * in: 'query', + * required: false, + * schema: { + * type: 'string', + * }, + * } -> 'string' + */ +export function getParameterType( + param: OA3.ParameterObject | OA3.MediaTypeObject, + options: Partial +): string { const unknownType = options.preferAny ? 'any' : 'unknown'; if (!param) { return unknownType; } - if (param.enum && !param['x-schema'] && !param.fullEnum) { - if (!param.type || param.type === 'string') { - return `'${param.enum.join(`'|'`)}'`; - } - if (param.type === 'integer' || param.type === 'number') { - return `${param.enum.join('|')}`; - } - } - if (param.$ref) { - const type = param.$ref.split('/').pop(); - return handleGenerics(type); - } - if (param.schema) { - return getTSParamType(param.schema, options); + + return getTypeFromSchema(param.schema, options); +} + +function getTypeFromSchema( + schema: OA3.SchemaObject | OA3.ReferenceObject, + options: Partial +): string { + const unknownType = options.preferAny ? 'any' : 'unknown'; + + if (!schema) { + return unknownType; } - if (param['x-schema']) { - return getTSParamType(param['x-schema'], options); + if ('$ref' in schema) { + const type = schema.$ref.split('/').pop(); + return handleGenerics(type || unknownType); } - if (param.type === 'array') { - if (param.items.type) { - if (param.items.enum) { - return `(${getTSParamType(param.items, options)})[]`; - } - return `${getTSParamType(param.items, options)}[]`; - } - if (param.items.$ref) { - const type = param.items.$ref.split('/').pop(); - return `${handleGenerics(type)}[]`; + + if (schema.type === 'array') { + if (schema.items) { + return `${getTypeFromSchema(schema.items, options)}[]`; } return `${unknownType}[]`; } - if (param.type === 'object') { - if (param.additionalProperties) { - const extraProps = param.additionalProperties; - return `{ [key: string]: ${getTSParamType(extraProps, options)} }`; + if (schema.type === 'object') { + if (schema.additionalProperties) { + const extraProps = schema.additionalProperties; + return `{ [key: string]: ${ + extraProps === true ? 'any' : getTypeFromSchema(extraProps, options) + } }`; } return unknownType; } - if (param.type === 'integer' || param.type === 'number') { + if (schema.type === 'integer' || schema.type === 'number') { return 'number'; } - if (param.type === 'string' && (param.format === 'date-time' || param.format === 'date')) { + if (schema.type === 'string' && (schema.format === 'date-time' || schema.format === 'date')) { return options.dateFormat === 'string' ? 'string' : 'Date'; } - if (param.type === 'string') { - return 'string'; - } - if (param.type === 'file') { - return 'File'; + if (schema.type === 'string') { + return schema.format === 'binary' ? 'File' : 'string'; } - if (param.type === 'boolean') { + if (schema.type === 'boolean') { return 'boolean'; } return unknownType; diff --git a/src/gen/templateManager.spec.ts b/src/gen/templateManager.spec.ts index 6edd9d8..e71ce91 100644 --- a/src/gen/templateManager.spec.ts +++ b/src/gen/templateManager.spec.ts @@ -7,19 +7,19 @@ import { loadAllTemplateFiles, renderFile } from './templateManager'; const GOOD_FILE = 'client.ejs'; describe('loadAllTemplateFiles', () => { - it('should handle loading wrong template', async () => { + it('should handle loading wrong template', () => { expect(() => { loadAllTemplateFiles('non-existent'); }).to.throw('Could not found'); }); - it('should handle empty template name', async () => { + it('should handle empty template name', () => { expect(() => { loadAllTemplateFiles(''); }).to.throw('No template'); }); - it('should handle null template', async () => { + it('should handle null template', () => { expect(() => { loadAllTemplateFiles(null); }).to.throw('No template'); diff --git a/src/gen/util.spec.ts b/src/gen/util.spec.ts index a856893..9302bea 100644 --- a/src/gen/util.spec.ts +++ b/src/gen/util.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { groupOperationsByGroupName, getBestResponse, prepareOutputFilename } from './util'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; describe('groupOperationsByGroupName', () => { it('handles null', async () => { @@ -160,7 +161,7 @@ describe('groupOperationsByGroupName', () => { }); describe('prepareOutputFilename', () => { - [ + for (const { given, expected } of [ { given: null, expected: null }, { given: 'api.ts', expected: 'api.ts' }, { given: 'api', expected: 'api.ts' }, @@ -170,56 +171,104 @@ describe('prepareOutputFilename', () => { { given: 'api//api.ts', expected: 'api//api.ts' }, { given: 'api\\api.ts', expected: 'api/api.ts' }, { given: 'api/api/', expected: 'api/api/index.ts' }, - ].forEach((el) => { - it(`handles ${el.given}`, () => { - const res = prepareOutputFilename(el.given); + ]) { + it(`handles "${given}" correctly`, () => { + const res = prepareOutputFilename(given); - expect(res).to.be.equal(el.expected); + expect(res).to.be.equal(expected); }); - }); + } }); describe('getBestResponse', () => { it('handles no responses', () => { - const op = { - responses: [], + const op: OA3.OperationObject = { + responses: {}, }; - const res = getBestResponse(op as any); + const res = getBestResponse(op); - expect(res).to.be.equal(undefined); + expect(res).to.be.equal(null); }); - it('handles one response', () => { - const op = { - responses: [{ code: '300' }], + it('handles 200 response with text/plain media type', () => { + const op: OA3.OperationObject = { + responses: { + '200': { + description: 'Success', + content: { + 'text/plain': { + schema: { + $ref: '#/components/schemas/TestObject', + }, + }, + }, + }, + }, }; - const res = getBestResponse(op as any); + const res = getBestResponse(op); - expect(res).to.be.eql({ code: '300' }); + expect(res).to.be.eql({ + schema: { + $ref: '#/components/schemas/TestObject', + }, + }); }); - it('handles multiple responses', () => { - const op = { - responses: [{ code: '404' }, { code: '200' }], + it('handles 201 response with unsupported media type', () => { + const op: OA3.OperationObject = { + responses: { + '201': { + description: 'Success', + content: { + 'application/octet-stream': { + schema: { + $ref: '#/components/schemas/TestObject', + }, + }, + }, + }, + }, }; - const res = getBestResponse(op as any); + const res = getBestResponse(op); - expect(res).to.be.eql({ code: '200' }); + expect(res).to.be.eql(null); }); - // TODO: This one does not make sense at all! - it('handles response without code (WTF?)', () => { - const first = { something: '404' }; - const second = { something: '200' }; - const op = { - responses: [first, second], + it('handles multiple responses', () => { + const op: OA3.OperationObject = { + responses: { + '301': { + description: 'Moved Permanently', + content: { + 'text/plain': { + schema: { + $ref: '#/components/schemas/Wrong', + }, + }, + }, + }, + '203': { + description: 'Success', + content: { + 'text/plain': { + schema: { + $ref: '#/components/schemas/TestObject', + }, + }, + }, + }, + }, }; - const res = getBestResponse(op as any); + const res = getBestResponse(op); - expect(res).to.be.eql(first); + expect(res).to.be.eql({ + schema: { + $ref: '#/components/schemas/TestObject', + }, + }); }); }); diff --git a/src/gen/util.ts b/src/gen/util.ts index d70a428..dd51dd0 100644 --- a/src/gen/util.ts +++ b/src/gen/util.ts @@ -1,7 +1,8 @@ import { type Stats, lstatSync, mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; import { dirname } from 'node:path'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import type { ApiOperation, ApiOperationResponse } from '../types'; +import type { ApiOperationResponse } from '../types'; export function exists(filePath: string): Stats { try { @@ -42,19 +43,30 @@ export function join(parent: string[], child: string[]): string[] { return parent; } -export function getBestResponse(op: ApiOperation): ApiOperationResponse { +/** + * Operations in OpenAPI can have multiple responses, but + * we are interested in the one that is the most common for + * a standard success response. And we need the content of it. + * Content is per media type and we need to choose only one. + * We will try to get the first one that is JSON or plain text. + * Other media types are not supported at this time. + * @returns Response or reference of the success response + */ +export function getBestResponse(op: OA3.OperationObject) { const NOT_FOUND = 100000; - const lowestCode = op.responses.reduce((code, resp) => { - const responseCode = Number.parseInt(resp.code, 10); - if (Number.isNaN(responseCode) || responseCode >= code) { - return code; - } - return responseCode; - }, NOT_FOUND); + const lowestCode = Object.keys(op.responses).sort().shift() ?? NOT_FOUND; + + const resp = lowestCode === NOT_FOUND ? op.responses[0] : op.responses[lowestCode.toString()]; - return lowestCode === NOT_FOUND - ? op.responses[0] - : op.responses.find((resp) => resp.code === lowestCode.toString()); + if (resp && 'content' in resp) { + return ( + resp.content['application/json'] ?? + resp.content['text/json'] ?? + resp.content['text/plain'] ?? + null + ); + } + return null; } /** This method tries to fix potentially wrong out parameter given from commandline */ @@ -67,9 +79,9 @@ export function prepareOutputFilename(out: string | null): string { return out.replace(/[\\]/i, '/'); } if (/[\/\\]$/i.test(out)) { - return out.replace(/[\/\\]$/i, '') + '/index.ts'; + return `${out.replace(/[\/\\]$/i, '')}/index.ts`; } - return out.replace(/[\\]/i, '/') + '.ts'; + return `${out.replace(/[\\]/i, '/')}.ts`; } export function uniq(arr?: T[]) { diff --git a/src/index.spec.ts b/src/index.spec.ts index 96ad827..e3154c2 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -8,43 +8,51 @@ import { runCodeGenerator, applyConfigFile } from './index'; describe('runCodeGenerator', () => { afterEach(sinon.restore); - it('fails with no parameters provided', () => { + it('fails with no parameters provided', async () => { const parameters = {}; - return runCodeGenerator(parameters as any).catch((e) => - expect(e).to.contain('You need to provide') - ); + try { + return await runCodeGenerator(parameters as any); + } catch (e) { + return expect(e.message).to.contain('You need to provide'); + } }); - it('fails with only --out provided', () => { + it('fails with only --out provided', async () => { const parameters = { out: './.tmp/test/', }; - return runCodeGenerator(parameters as any).catch((e) => - expect(e).to.contain('You need to provide') - ); + try { + return await runCodeGenerator(parameters as any); + } catch (e) { + return expect(e.message).to.contain('You need to provide'); + } }); - it('fails with both --config and --src provided', () => { + it('fails with both --config and --src provided', async () => { const parameters = { config: './test/sample-config.json', src: 'https://google.pl', }; - return runCodeGenerator(parameters as any).catch((e) => - expect(e).to.contain('You need to provide') - ); + try { + return await runCodeGenerator(parameters as any); + } catch (e) { + return expect(e.message).to.contain('You need to provide'); + } }); - it('fails when there is no --config or --src provided', () => { + it('fails when there is no --config or --src provided', async () => { const parameters = { out: './.tmp/test/', }; - return runCodeGenerator(parameters as any).catch((e) => - expect(e).to.contain('You need to provide') - ); + try { + await runCodeGenerator(parameters as any); + } catch (e) { + return expect(e.message).to.contain('You need to provide'); + } }); it('works with --out and --src provided', () => { @@ -104,7 +112,9 @@ describe('runCodeGenerator', () => { const conf = await applyConfigFile(parameters as any); expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://google.pl'); - expect(conf.src).to.be.equal('https://petstore.swagger.io/v2/swagger.json'); + expect(conf.src).to.be.equal( + 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json' + ); }); it('makes inline parameters higher priority than from config file', async () => { diff --git a/src/types.ts b/src/types.ts index 616c87b..cc02e9e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,21 +26,6 @@ export type Template = 'axios' | 'fetch' | 'ng1' | 'ng2' | 'swr-axios' | 'xior'; export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch'; export type DateSupport = 'string' | 'Date'; // 'luxon', 'momentjs', etc -export interface ApiOperation { - id: string; - summary: string; - description: string; - method: HttpMethod; - group: string | null; - path: string; - parameters: ApiOperationParam[]; - responses: ApiOperationResponse[]; - security?: ApiOperationSecurity[]; - accepts: string[]; - contentTypes: string[]; - tags?: string[]; -} - export interface ApiOperationParam extends ApiOperationParamBase { name: string; in: 'header' | 'path' | 'query' | 'body' | 'formData'; diff --git a/wallaby.js b/wallaby.js new file mode 100644 index 0000000..d823400 --- /dev/null +++ b/wallaby.js @@ -0,0 +1,12 @@ +module.exports = () => ({ + files: ['src/**/*.ts', '!src/**/*.spec.ts'], + + tests: ['src/**/*.spec.ts'], + + env: { + type: 'node', + }, + + testFramework: 'mocha', + autoDetect: false +}); From 01c3da2abcd848ed390d8d6178ccac1a923bdba7 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Mon, 1 Jul 2024 18:34:34 +0200 Subject: [PATCH 04/27] feat: implement most of type generation part for OpenAPI 3 fix: adjustments for Swashbuckle generation of more complex query parameters chore: remove queryParams config flag, as it won't be needed anymore --- README.md | 2 - api.config.json | 1 - package.json | 1 - .../.config/dotnet-tools.json | 2 +- .../swashbuckle/Swaggie.Swashbuckle/.env | 1 - .../Controllers/UserController.cs | 100 ++- .../Swaggie.Swashbuckle/QueryFilter.cs | 81 ++ .../Swaggie.Swashbuckle/Startup.cs | 20 +- schema.json | 5 - src/cli.ts | 6 +- src/gen/js/createBarrel.ts | 2 +- src/gen/js/genOperations.spec.ts | 225 +----- src/gen/js/genOperations.ts | 189 ++--- src/gen/js/genTypes.spec.ts | 723 ++++++++---------- src/gen/js/genTypes.ts | 346 ++++----- src/gen/js/index.ts | 10 +- src/gen/js/models.ts | 18 - src/gen/js/support.spec.ts | 29 +- src/gen/js/support.ts | 18 +- src/gen/util.ts | 18 +- src/index.spec.ts | 7 +- src/index.ts | 4 +- src/swagger/operations.spec.ts | 47 +- src/swagger/operations.ts | 80 +- src/types.ts | 25 +- src/utils/documentLoader.ts | 7 - src/utils/index.ts | 1 + src/utils/test.utils.ts | 29 + test/ci-test.config.json | 1 - test/index.d.ts | 6 +- test/sample-config.json | 1 - test/snapshots.spec.ts | 7 +- yarn.lock | 5 - 33 files changed, 854 insertions(+), 1163 deletions(-) delete mode 100644 samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.env create mode 100644 samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs create mode 100644 src/utils/test.utils.ts diff --git a/README.md b/README.md index e852430..edee1c9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ Options: -b, --baseUrl Base URL that will be used as a default value in the clients. Default: "" --preferAny Use "any" type instead of "unknown". Default: false --servicePrefix Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions. Default: '' - --queryModels Generate models for query string instead list of parameters. Default: false ``` Sample CLI usage using Swagger's Pet Store: @@ -77,7 +76,6 @@ Sample configuration looks like this: "baseUrl": "/api", "preferAny": true, "servicePrefix": "", - "queryModels": true, "dateFormat": "Date" // "string" | "Date" } ``` diff --git a/api.config.json b/api.config.json index 883f285..9c8adf3 100644 --- a/api.config.json +++ b/api.config.json @@ -4,6 +4,5 @@ "src": "https://petstore.swagger.io/v2/swagger.json", "template": "ng2", "preferAny": true, - "queryModels": true, "dateFormat": "string" } diff --git a/package.json b/package.json index 83192ef..1d85150 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "dependencies": { "case": "^1.6.3", "commander": "^10.0.0", - "dset": "^3.1.3", "eta": "^3.4.0", "js-yaml": "^4.1.0", "nanocolors": "^0.2.0", diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.config/dotnet-tools.json b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.config/dotnet-tools.json index 1a1607f..fe723f0 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.config/dotnet-tools.json +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "6.2.3", + "version": "6.6.2", "commands": [ "swagger" ] diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.env b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.env deleted file mode 100644 index f0e9f92..0000000 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/.env +++ /dev/null @@ -1 +0,0 @@ -ASPNETCORE_ENVIRONMENT=dev \ No newline at end of file diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs index fc32d91..2139b71 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs @@ -1,7 +1,10 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; namespace Swaggie.Swashbuckle.Controllers; @@ -31,6 +34,43 @@ public IActionResult GetUsers([FromQuery] UserRole? role) return Ok(users); } + [HttpGet("filter")] + [Produces(typeof(PagedResult))] + public IActionResult FilterUsers([FromQuery(Name = "filter")] UserFilter? filter, [FromQuery(Name = "secondFilter")] UserFilter? secondFilter, [FromQuery] Dictionary someDict) + { + Console.WriteLine(JsonConvert.SerializeObject(filter)); + Console.WriteLine(JsonConvert.SerializeObject(secondFilter)); + var allUsers = new[] + { + new UserViewModel + { + Name = "Ann Bobcat", Id = 1, Email = "ann.b@test.org", Role = UserRole.Admin + }, + new UserViewModel + { + Name = "Bob Johnson", Id = 2, Email = "bob.j@test.org", Role = UserRole.User + } + }; + + if (filter == null) + { + return Ok(allUsers); + } + + var users = allUsers + .Where(u => filter.Roles.Count > 0 || filter.Roles.Contains(u.Role)) + // Rest of the filtering logic + .ToList(); + + var result = new PagedResult + { + Items = users, + TotalCount = users.Count + }; + + return Ok(result); + } + [HttpPost("")] [ProducesResponseType(typeof(UserViewModel), StatusCodes.Status201Created)] public IActionResult CreateUser([FromBody] UserViewModel user) @@ -38,7 +78,7 @@ public IActionResult CreateUser([FromBody] UserViewModel user) return Created("some-url", user); } - [HttpDelete("{id}")] + [HttpDelete("{id:long}")] [Produces(typeof(void))] public IActionResult DeleteUser([FromRoute] long id) { @@ -55,6 +95,61 @@ public class UserViewModel public string Email { get; set; } public UserRole Role { get; set; } + + [Required] + public Dictionary SomeDict { get; set; } = new(); + + public PagedResult AuditEvents { get; set; } = new(); +} + +public class UserFilter +{ + /// + /// Name of the user. Can be partial name match + /// + public string Name { get; set; } + + /// + /// Ids of the users + /// + public List Ids { get; set; } = new(); + + /// + /// User's email. Can be partial match + /// + public string Email { get; set; } + + /// + /// Search by user role(s) + /// + public List Roles { get; set; } = new(); + + public UserLog UserLog { get; set; } = new(); +} + +public class PagedResult +{ + public IList Items { get; set; } + + public int TotalCount { get; set; } +} + +public class UserLog +{ + /// + /// Who created user. Can be partial name match + /// + public string CreatedBy { get; set; } + + /// + /// From date for user creation + /// + public DateTime DateFrom { get; set; } + + /// + /// End date for user creation + /// + public DateTime DateTo { get; set; } } public enum UserRole @@ -63,4 +158,3 @@ public enum UserRole User = 1, Guest = 2 } - diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs new file mode 100644 index 0000000..9592097 --- /dev/null +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Collections.Generic; +using System.Linq; + +namespace Swaggie.Swashbuckle; + +public class FromQueryModelFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var description = context.ApiDescription; + if (description.HttpMethod?.ToLower() != HttpMethod.Get.ToString().ToLower()) + { + // We only want to do this for GET requests, if this is not a + // GET request, leave this operation as is, do not modify + return; + } + + var actionParameters = description.ActionDescriptor.Parameters; + var apiParameters = description.ParameterDescriptions + .Where(p => p.Source.IsFromRequest) + .ToList(); + + if (actionParameters.Count == apiParameters.Count) + { + // If no complex query parameters detected, leave this operation as is, do not modify + return; + } + + operation.Parameters = CreateParameters(actionParameters, operation.Parameters, context); + } + + private List CreateParameters( + IList actionParameters, + IList operationParameters, + OperationFilterContext context) + { + var newParameters = actionParameters + .Select(p => CreateParameter(p, operationParameters, context)) + .Where(p => p != null) + .ToList(); + + return newParameters.Count != 0 ? newParameters : null; + } + + private OpenApiParameter CreateParameter( + ParameterDescriptor actionParameter, + IList operationParameters, + OperationFilterContext context) + { + var operationParamNames = operationParameters.Select(p => p.Name); + if (operationParamNames.Contains(actionParameter.Name)) + { + // If param is defined as the action method argument, just pass it through + return operationParameters.First(p => p.Name == actionParameter.Name); + } + + if (actionParameter.BindingInfo == null) + { + return null; + } + + var generatedSchema = + context.SchemaGenerator.GenerateSchema(actionParameter.ParameterType, + context.SchemaRepository); + + var newParameter = new OpenApiParameter + { + Name = actionParameter.Name, + In = ParameterLocation.Query, + Schema = generatedSchema, + Explode = true, + Style = ParameterStyle.Simple + }; + + return newParameter; + } +} diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs index a334d02..412c8d0 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; @@ -44,8 +43,11 @@ public void ConfigureServices(IServiceCollection services) if (!_isProduction) { + services.AddEndpointsApiExplorer(); services.AddSwaggerGen(c => { + c.SchemaGeneratorOptions.UseOneOfForPolymorphism = true; + c.OperationFilter(); c.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); c.SwaggerDoc("v1", new OpenApiInfo { Title = "Sample Api", Version = "v1" }); @@ -67,10 +69,18 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, if (!_isProduction) { - app.UseSwagger(c => c.SerializeAsV2 = true); - app.UseSwaggerUI(c => { c.SwaggerEndpoint("v1/swagger.json", "Sample Api"); }); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Sample Api"); + c.RoutePrefix = "swagger"; + }); } - app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapSwagger(); + }); } } diff --git a/schema.json b/schema.json index 671db10..04fc21a 100644 --- a/schema.json +++ b/schema.json @@ -38,11 +38,6 @@ "description": "Use `any` type instead of `unknown`", "type": "boolean" }, - "queryModels": { - "default": false, - "description": "Generate models for query string instead list of parameters", - "type": "boolean" - }, "servicePrefix": { "default": "", "description": "Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions", diff --git a/src/cli.ts b/src/cli.ts index 3139f70..586178b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,10 +35,6 @@ program .option( '--servicePrefix ', 'Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions. Default: ""' - ) - .option( - '--queryModels', - 'Generate models for query string instead list of parameters. Default: false' ); program.parse(process.argv); @@ -58,7 +54,7 @@ function complete([code, opts]: CodeGenResult) { process.exit(0); } -function error(e) { +function error(e: any) { const msg = e instanceof Error ? e.message : e; console.error(red(msg)); process.exit(1); diff --git a/src/gen/js/createBarrel.ts b/src/gen/js/createBarrel.ts index 8e1278c..b6927e4 100644 --- a/src/gen/js/createBarrel.ts +++ b/src/gen/js/createBarrel.ts @@ -2,7 +2,7 @@ import { camel } from 'case'; import type { ClientOptions } from '../../types'; import { renderFile } from '../templateManager'; -export async function generateBarrelFile(clients: any[], clientOptions: ClientOptions) { +export function generateBarrelFile(clients: any[], clientOptions: ClientOptions) { const files = []; for (const name in clients) { diff --git a/src/gen/js/genOperations.spec.ts b/src/gen/js/genOperations.spec.ts index 210a50b..f68322f 100644 --- a/src/gen/js/genOperations.spec.ts +++ b/src/gen/js/genOperations.spec.ts @@ -35,20 +35,18 @@ describe('prepareOperations', () => { // }); it(`operation's empty header list should be handled correctly`, () => { - const ops = [ + const ops: ApiOperation[] = [ { - id: 'getPetById', + operationId: 'getPetById', summary: 'Find pet by ID', description: 'Returns a single pet', method: 'get', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: [], }, - ] as ApiOperation[]; + ]; const [res] = prepareOperations(ops, {} as any); @@ -179,38 +177,9 @@ describe('prepareOperations', () => { accepts: ['application/json'], contentTypes: [], } as unknown as ApiOperation; - - it('query model should be generated instead array of params', () => { - const expectedQueryType = 'IGetPetByIdFromPetServiceQuery'; - - const [res, queryDefs] = prepareOperations([op], { - queryModels: true, - } as any); - - expect(queryDefs[expectedQueryType]).to.be.ok; - expect(queryDefs[expectedQueryType].type).to.be.equal('object'); - expect(queryDefs[expectedQueryType].properties).to.be.eql({ - firstParameter: op.parameters[0], - secondParameter: op.parameters[1], - filter_anotherParameter: op.parameters[2], - }); - - expect(res[0]).to.be.ok; - expect(res[0].parameters.length).to.be.equal(1); - expect(res[0].parameters[0].name).to.be.equal('petGetPetByIdQuery'); - expect(res[0].parameters[0].type).to.be.equal(expectedQueryType); - }); - - it('query model should not be generated', () => { - const [res, queryDef] = prepareOperations([op], {} as any); - - expect(queryDef).to.be.eql({}); - expect(res[0]).to.be.ok; - expect(res[0].parameters.length).to.be.equal(op.parameters.length); - }); }); - it(`formdata array param should be serialized correctly as array`, () => { + it('formdata array param should be serialized correctly as array', () => { const ops = [ { id: 'getPetById', @@ -247,7 +216,7 @@ describe('prepareOperations', () => { }); describe('fixDuplicateOperations', () => { - it(`handle empty list`, () => { + it('handle empty list', () => { const ops = []; const res = fixDuplicateOperations(ops); @@ -277,33 +246,29 @@ describe('fixDuplicateOperations', () => { expect(res).to.be.deep.equal(ops); }); - it(`handle 2 different operations`, () => { - const ops = [ + it('handle 2 different operations', () => { + const ops: ApiOperation[] = [ { - id: 'getPetById', + operationId: 'getPetById', summary: 'Find pet by ID', description: 'Returns a single pet', method: 'get', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: ['application/x-www-form-urlencoded'], }, { - id: 'somethingElse', + operationId: 'somethingElse', summary: 'Random', description: 'Random', method: 'get', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: [], }, - ] as ApiOperation[]; + ]; const res = fixDuplicateOperations(ops); @@ -311,8 +276,8 @@ describe('fixDuplicateOperations', () => { expect(res).to.be.deep.equal(ops); }); - it(`handle 2 operations with the same id`, () => { - const ops = [ + it('handle 2 operations with the same id', () => { + const ops: ApiOperation[] = [ { id: 'getPetById', summary: 'Find pet by ID', @@ -320,10 +285,7 @@ describe('fixDuplicateOperations', () => { method: 'get', path: '/pet/{petId}', parameters: [], - responses: [], group: null, - accepts: ['application/json'], - contentTypes: [], }, { id: 'getPetById', @@ -332,12 +294,9 @@ describe('fixDuplicateOperations', () => { method: 'post', path: '/pet/{petId}', parameters: [], - responses: [], group: null, - accepts: ['application/json'], - contentTypes: [], }, - ] as ApiOperation[]; + ]; const res = fixDuplicateOperations(ops); @@ -417,155 +376,3 @@ describe('getOperationName', () => { }); }); }); - -describe('x-schema extension', () => { - it(`handle x-schema simple case in operation parameter`, () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - type: 'object', - name: 'something', - in: 'query', - 'x-schema': { - $ref: '#/definitions/SomeType', - }, - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: [], - }, - ]; - - const res = prepareOperations(ops as unknown as ApiOperation[], {} as any); - - expect(res).to.be.ok; - expect(res[0][0].parameters[0]).to.be.deep.include({ - name: 'something', - originalName: 'something', - type: 'SomeType', - optional: true, - }); - }); - - it(`handle x-schema with enum in operation parameter`, () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - type: 'integer', - name: 'something', - in: 'query', - 'x-schema': { - $ref: '#/definitions/SomeType', - }, - enum: [1, 2], - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: [], - }, - ]; - - const res = prepareOperations(ops as unknown as ApiOperation[], {} as any); - - expect(res).to.be.ok; - expect(res[0][0].parameters[0]).to.be.deep.include({ - name: 'something', - originalName: 'something', - type: 'SomeType', - optional: true, - }); - }); - - it(`handle x-nullable as false correctly`, () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - type: 'integer', - name: 'something', - in: 'query', - 'x-schema': { - $ref: '#/definitions/SomeType', - }, - 'x-nullable': false, - enum: [1, 2], - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: [], - }, - ]; - - const res = prepareOperations(ops as unknown as ApiOperation[], {} as any); - - expect(res).to.be.ok; - expect(res[0][0].parameters[0]).to.be.deep.include({ - name: 'something', - originalName: 'something', - type: 'SomeType', - optional: false, - }); - }); - - it(`handle x-nullable as true correctly`, () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - type: 'integer', - name: 'something', - in: 'query', - 'x-schema': { - $ref: '#/definitions/SomeType', - }, - 'x-nullable': true, - enum: [1, 2], - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: [], - }, - ]; - - const [resOps, resDefs] = prepareOperations(ops as unknown as ApiOperation[], {} as any); - - expect(resOps).to.be.ok; - expect(resDefs).to.be.ok; - expect(resOps[0].parameters[0]).to.be.deep.include({ - name: 'something', - originalName: 'something', - type: 'SomeType', - optional: true, - }); - }); -}); diff --git a/src/gen/js/genOperations.ts b/src/gen/js/genOperations.ts index af54fe8..0057ede 100644 --- a/src/gen/js/genOperations.ts +++ b/src/gen/js/genOperations.ts @@ -1,136 +1,106 @@ import { camel } from 'case'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { getParameterType } from './support'; -import { groupOperationsByGroupName, getBestResponse, orderBy, upperFirst } from '../util'; -import type { - IServiceClient, - IApiOperation, - IOperationParam, - IQueryDefinitions, - IQueryPropDefinition, -} from './models'; +import { groupOperationsByGroupName, getBestResponse, orderBy } from '../util'; +import type { IServiceClient, IApiOperation, IOperationParam } from './models'; import { generateBarrelFile } from './createBarrel'; import { renderFile } from '../templateManager'; -import type { ApiSpec, ApiOperation, ClientOptions, ApiOperationParam } from '../../types'; +import type { ApiOperation, ClientOptions } from '../../types'; import { escapeReservedWords } from '../../utils'; -const MAX_QUERY_PARAMS: number = 1; - export default async function genOperations( - spec: ApiSpec, + spec: OA3.Document, operations: ApiOperation[], options: ClientOptions -): Promise<[string, IQueryDefinitions]> { +): Promise { const groups = groupOperationsByGroupName(operations); let result = - (await renderFile('baseClient.ejs', { + renderFile('baseClient.ejs', { servicePrefix: options.servicePrefix || '', baseUrl: options.baseUrl, - })) || ''; - let queryDefinitions = {} as IQueryDefinitions; + }) || ''; for (const name in groups) { const group = groups[name]; - const [clientData, clientQueryDefinitions] = prepareClient( - (options.servicePrefix || '') + name, - group, - options - ); + const clientData = prepareClient((options.servicePrefix ?? '') + name, group, options); - const renderedFile = await renderFile('client.ejs', { + const renderedFile = renderFile('client.ejs', { ...clientData, servicePrefix: options.servicePrefix || '', }); result += renderedFile || ''; - - queryDefinitions = { - ...queryDefinitions, - ...clientQueryDefinitions, - }; } - result += await generateBarrelFile(groups, options); + result += generateBarrelFile(groups, options); - return [result, queryDefinitions]; + return result; } function prepareClient( name: string, - operations: ApiOperation[], + operations: OA3.OperationObject[], options: ClientOptions -): [IServiceClient, IQueryDefinitions] { - const [preparedOperations, queryDefinitions] = prepareOperations(operations, options); - return [ - { - clientName: name, - camelCaseName: camel(name), - operations: preparedOperations, - baseUrl: options.baseUrl, - }, - queryDefinitions, - ]; +): IServiceClient { + const preparedOperations = prepareOperations(operations, options); + + return { + clientName: name, + camelCaseName: camel(name), + operations: preparedOperations, + baseUrl: options.baseUrl, + }; } export function prepareOperations( - operations: ApiOperation[], + operations: OA3.OperationObject[], options: ClientOptions -): [IApiOperation[], IQueryDefinitions] { +): IApiOperation[] { const ops = fixDuplicateOperations(operations); - const queryDefinitions = {} as IQueryDefinitions; - return [ - ops.map((op) => { - const response = getBestResponse(op); - const respType = getParameterType(response, options); - - let queryParams = getParams(op.parameters, options, ['query']); - let params = getParams(op.parameters, options); - - if (options.queryModels && queryParams.length > MAX_QUERY_PARAMS) { - const [newQueryParam, queryParamDefinition] = getQueryDefinition(queryParams, op, options); - - [params, queryParams] = addQueryModelToParams(params, queryParams, newQueryParam); - queryDefinitions[newQueryParam.type] = queryParamDefinition; - } - - return { - returnType: respType, - method: op.method.toUpperCase(), - name: getOperationName(op.id, op.group), - url: op.path, - parameters: params, - query: queryParams, - formData: getParams(op.parameters, options, ['formData']), - pathParams: getParams(op.parameters, options, ['path']), - body: getParams(op.parameters, options, ['body']).pop(), - headers: getHeaders(op, options), - }; - }), - queryDefinitions, - ]; + return ops.map((op) => { + const response = getBestResponse(op); + const respType = getParameterType(response, options); + + const queryParams = getParams(op.parameters, options, ['query']); + const params = getParams(op.parameters, options); + + return { + returnType: respType, + method: op.method.toUpperCase(), + name: getOperationName(op.operationId, op.group), + url: op.path, + parameters: params, + query: queryParams, + formData: getParams(op.parameters, options, ['formData']), + pathParams: getParams(op.parameters, options, ['path']), + body: getParams(op.parameters, options, ['body']).pop(), + headers: getHeaders(op, options), + }; + }); } /** * We will add numbers to the duplicated operation names to avoid breaking code * @param operations */ -export function fixDuplicateOperations(operations: ApiOperation[]): ApiOperation[] { +export function fixDuplicateOperations(operations: OA3.OperationObject[]): OA3.OperationObject[] { if (!operations || operations.length < 2) { return operations; } const ops = operations.map((a) => Object.assign({}, a)); - const results = orderBy(ops, 'id'); + const results = orderBy(ops, 'operationId'); let inc = 0; - let prevOpId = results[0].id; + let prevOpId = results[0].operationId; for (let i = 1; i < results.length; i++) { - if (results[i].id === prevOpId) { - results[i].id += (++inc).toString(); + if (results[i].operationId === prevOpId) { + results[i].operationId += (++inc).toString(); } else { inc = 0; - prevOpId = results[i].id; + prevOpId = results[i].operationId; } } @@ -148,7 +118,7 @@ export function getOperationName(opId: string | null, group?: string | null) { return camel(opId.replace(`${group}_`, '')); } -function getHeaders(op: ApiOperation, options: ClientOptions): IOperationParam[] { +function getHeaders(op: OA3.OperationObject, options: ClientOptions): IOperationParam[] { const headersFromParams = getParams(op.parameters, options, ['header']); // TODO: At some point there may be need for a new param to add implicitly default content types // TODO: At this time content-type support was not essential to move forward with this functionality @@ -215,60 +185,3 @@ export function getParamName(name: string): string { .join('_') ); } - -/** - * Converts object notation to a safe one + escapes reserved words - * @example `a.b.c` -> `a?.b?.c` - */ -function makeSafeQueryNames(name: string): string { - return escapeReservedWords(name.replace(/\./g, '?.')); -} - -function addQueryModelToParams( - params: IOperationParam[], - queryParams: IOperationParam[], - queryParam: IOperationParam -): [IOperationParam[], IOperationParam[]] { - const filteredParams = params.filter((x) => !queryParams.find((y) => y.name === x.name)); - filteredParams.push(queryParam); - - const updatedQueryParams = queryParams.map((x) => ({ - ...x, - name: `${queryParam.name}.${makeSafeQueryNames(x.originalName)}`, - })); - - return [filteredParams, updatedQueryParams]; -} - -/** - * Prepares a new parameter that exposes other client parameters - */ -function getQueryDefinition( - queryParams: IOperationParam[], - op: ApiOperation, - options: ClientOptions -): [IOperationParam, IQueryPropDefinition] { - const queryParam = { - originalName: `${op.id.replace('_', '')}Query`, - name: getParamName(`${op.id.replace('_', '')}Query`), - type: `I${upperFirst(getOperationName(op.id, op.group))}From${options.servicePrefix || ''}${ - op.group - }ServiceQuery`, - optional: false, - } as IOperationParam; - - const queryParamDefinition = { - type: 'object', - required: [], - queryParam: true, - properties: queryParams.reduce( - (prev, curr) => ({ - ...prev, - [curr.name]: curr.original, - }), - {} - ), - } as IQueryPropDefinition; - - return [queryParam, queryParamDefinition]; -} diff --git a/src/gen/js/genTypes.spec.ts b/src/gen/js/genTypes.spec.ts index fdd7ecd..607473b 100644 --- a/src/gen/js/genTypes.spec.ts +++ b/src/gen/js/genTypes.spec.ts @@ -1,142 +1,191 @@ import { expect } from 'chai'; -import type { ApiSpec } from '../../types'; -import genTypes, { renderQueryStringParameters, renderComment } from './genTypes'; - -const emptySpec: ApiSpec = { - swagger: '2.0', - info: { - title: 'Some Api', - version: 'v1', - }, - paths: [], - definitions: [], - accepts: [], - contentTypes: [], -}; +import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; + +import genTypes, { renderComment } from './genTypes'; +import { getClientOptions, getDocument } from '../../utils'; describe('genTypes', () => { - it('should handle empty definitions properly', () => { - const res = genTypes(emptySpec, {}, {} as any); + const opts = getClientOptions(); + + it('should handle empty components properly', () => { + const res = genTypes(getDocument({ components: {} }), opts); expect(res).to.be.equal(''); }); - describe('enums', () => { - describe('int-serialized simple enums', () => { - it(`should handle Swashbuckle's enum correctly`, () => { - const res = genTypes( - emptySpec, - { - SomeEnum: { - format: 'int32', - enum: [0, 1], - type: 'integer', - }, - }, - {} as any - ); + it('should handle empty components schemas properly', () => { + const res = genTypes(getDocument({ components: { schemas: {} } }), opts); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal('export type SomeEnum = 0 | 1;'); - }); + expect(res).to.be.equal(''); + }); - it(`should handle NSwag's enum correctly`, () => { - const res = genTypes( - emptySpec, - { - SomeEnum: { - type: 'integer', - format: 'int32', - enum: ['Active', 'Disabled'], - fullEnum: { - Active: 0, - Disabled: 1, - }, - }, - }, - {} as any - ); + it('should handle schema with reference only', () => { + const res = genTypes( + prepareSchemas({ + A: { + $ref: '#/components/schemas/B', + }, + B: { + type: 'string', + }, + }), + opts + ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export enum SomeEnum { - Active = 0, - Disabled = 1, -}`); - }); - }); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal( + `export type A = B; +export interface B { +}` + ); + }); - describe('string-serialized simple enums', () => { - it(`should handle Swashbuckle's enum correctly`, () => { - const res = genTypes( - emptySpec, - { - SomeEnum: { - enum: ['Active', 'Disabled'], - type: 'string', - }, + describe('enums', () => { + it('should handle simple enums correctly', () => { + const res = genTypes( + prepareSchemas({ + SimpleEnum: { + type: 'integer', + format: 'int32', + enum: [0, 1], }, - {} as any - ); + StringEnum: { + type: 'string', + description: 'Feature is activated or not', + enum: ['Active', 'Disabled'], + }, + }), + opts + ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export type SomeEnum = 'Active' | 'Disabled';`); - }); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal( + `export type SimpleEnum = 0 | 1; + +// Feature is activated or not +export type StringEnum = "Active" | "Disabled";` + ); }); - }); - describe('x-enums', () => { - it('should handle number-based x-enums correctly', () => { + it('should handle extended enums correctly', () => { const res = genTypes( - emptySpec, - { - GrantType: { + prepareSchemas({ + XEnums: { type: 'integer', - description: '', - 'x-enumNames': ['None', 'Password', 'External', 'Internal'], - enum: [0, 1, 2, 3], + format: 'int32', + enum: [2, 1, 0], + 'x-enumNames': ['High', 'Medium', 'Low'], }, - }, - {} as any + XEnumVarnames: { + type: 'integer', + format: 'int32', + enum: [2, 1, 0], + 'x-enum-varnames': ['High', 'Medium', 'Low'], + }, + XEnumsString: { + type: 'string', + enum: ['L', 'M', 'S'], + description: 'How big the feature is', + 'x-enumNames': ['Large', 'Medium', 'Small'], + }, + }), + opts ); expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export enum GrantType { - None = 0, - Password = 1, - External = 2, - Internal = 3, -}`); + expect(res.trim()).to.be.equal( + `export enum XEnums { + High = 2, + Medium = 1, + Low = 0, +} + +export enum XEnumVarnames { + High = 2, + Medium = 1, + Low = 0, +} + +// How big the feature is +export enum XEnumsString { + Large = "L", + Medium = "M", + Small = "S", +}` + ); }); - it('should handle string-based x-enums correctly', () => { + it('should handle OpenApi 3.1 enums', () => { const res = genTypes( - emptySpec, - { - GrantType: { + prepareSchemas({ + Priority: { + type: 'integer', + format: 'int32', + oneOf: [ + { title: 'High', const: 2, description: 'High priority' }, + { title: 'Medium', const: 1, description: 'Medium priority' }, + { title: 'Low', const: 0, description: 'Low priority' }, + ], + }, + Size: { type: 'string', - description: '', - 'x-enumNames': ['None', 'Password', 'External', 'Internal'], - enum: ['None', 'Password', 'External', 'Internal'], + description: 'How big the feature is', + oneOf: [ + { title: 'Large', const: 'L', description: 'Large size' }, + { title: 'Medium', const: 'M', description: 'Medium size' }, + { title: 'Small', const: 'S', description: 'Small size' }, + ], }, - }, - {} as any + }), + opts ); expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export enum GrantType { - None = "None", - Password = "Password", - External = "External", - Internal = "Internal", -}`); + expect(res.trim()).to.be.equal( + `export enum Priority { + High = 2, + Medium = 1, + Low = 0, +} + +// How big the feature is +export enum Size { + Large = "L", + Medium = "M", + Small = "S", +}` + ); }); + + // it("should handle NSwag's enum correctly", () => { + // const res = genTypes( + // getDocument(), + // { + // SomeEnum: { + // type: 'integer', + // format: 'int32', + // enum: ['Active', 'Disabled'], + // fullEnum: { + // Active: 0, + // Disabled: 1, + // }, + // }, + // }, + // {} as any + // ); + + // expect(res).to.be.ok; + // expect(res.trim()).to.be.equal(`export enum SomeEnum { + // Active = 0, + // Disabled = 1, + // }`); + // }); }); - describe('normal objects', () => { + describe('objects', () => { it('should handle obj with no required fields', () => { const res = genTypes( - emptySpec, - { + prepareSchemas({ AuthenticationData: { type: 'object', properties: { @@ -146,302 +195,197 @@ describe('genTypes', () => { password: { type: 'string', }, - } as any, + }, }, - }, - {} as any + Empty: { + type: 'object', + }, + }), + opts ); expect(res).to.be.ok; expect(res.trim()).to.be.equal(`export interface AuthenticationData { login?: string; - password?: string; + password?: string;} + +export interface Empty { }`); }); - it('should handle obj with all required fields', () => { + it('should handle obj with required fields', () => { const res = genTypes( - emptySpec, - { + prepareSchemas({ AuthenticationData: { type: 'object', + required: ['login', 'password'], properties: { login: { + // ReadOnly or WriteOnly are not yet supported + // As we don't have a way to distinguish how dev will use + // generated types in his app + readOnly: true, type: 'string', }, password: { + writeOnly: true, type: 'string', }, - } as any, - required: ['login', 'password'], + rememberMe: { + type: 'boolean', + }, + }, }, - }, - {} as any + }), + opts ); expect(res).to.be.ok; expect(res.trim()).to.be.equal(`export interface AuthenticationData { login: string; password: string; -}`); + rememberMe?: boolean;}`); }); }); - describe('objects with read-only fields', () => { - it('should ignore read-only fields', () => { - const res = genTypes( - emptySpec, - { - PagedAndSortedQuery: { - properties: { - isPagingSpecified: { - readOnly: true, - type: 'boolean', - }, - sortField: { - type: 'string', - }, - } as any, - required: [], - type: 'object', - }, - }, - {} as any - ); + describe('inheritance', () => { + describe('allOf', () => { + it('should handle 2 allOf correctly (most common case)', () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + allOf: [ + { + type: 'object', + properties: { + rememberMe: { + type: 'boolean', + }, + }, + }, + { $ref: '#/components/schemas/BasicAuth' }, + ], + }, + }), + opts + ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export interface PagedAndSortedQuery { - sortField?: string; -}`); - }); - }); -}); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal(`export interface AuthenticationData extends BasicAuth { + rememberMe?: boolean;}`); + }); -describe('renderQueryStringParameters', () => { - it('should handle empty list correctly', () => { - const def = { - type: 'object', - required: [], - queryParam: true, - properties: {}, - }; - const res = renderQueryStringParameters(def, {} as any); - - expect(res).to.deep.equal([]); - }); + it('should handle many allOf correctly', () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + allOf: [ + { $ref: '#/components/schemas/LoginPart' }, + { $ref: '#/components/schemas/PasswordPart' }, + { + type: 'object', + required: ['rememberMe'], + properties: { + rememberMe: { + type: 'boolean', + }, + }, + }, + { + type: 'object', + properties: { + signForSpam: { + type: 'boolean', + }, + }, + }, + ], + }, + }), + opts + ); - it('should handle one element without dots', () => { - const def = { - type: 'object', - required: [], - queryParam: true, - properties: { - page: { - name: 'page', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - }, - }; - const res = renderQueryStringParameters(def, {} as any); + expect(res).to.be.ok; + expect(res.trim()).to.be + .equal(`export interface AuthenticationData extends LoginPart, PasswordPart { + rememberMe: boolean; + signForSpam?: boolean;}`); + }); + }); - expect(res).to.be.ok; - expect(res.length).to.eq(1); - expect(res[0]).to.contain('page?: number;'); - }); + // Use of `anyOf` and `oneOf` is implemented in the same and very simple way + // We just list all the types in the union. This is close enough to the truth + // and should be convenient for the end user of Swaggie. - it('should handle one element in a dot notation', () => { - const def = { - type: 'object', - required: [], - queryParam: true, - properties: { - parameters_page: { - name: 'parameters.page', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - }, - }; - const res = renderQueryStringParameters(def, {} as any); + describe('anyOf', () => { + it('should handle 1 anyOf with reference correctly', () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + anyOf: [{ $ref: '#/components/schemas/BasicAuth' }], + }, + }), + opts + ); - expect(res).to.be.ok; - expect(res.length).to.eq(1); - expect(textOnly(res[0])).to.be.equal(textOnly('parameters?: {page?: number; }')); - }); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth;'); + }); - it('should handle two elements in a dot notation', () => { - const def = { - type: 'object', - required: [], - queryParam: true, - properties: { - parameters_page: { - name: 'parameters.page', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - parameters_count: { - name: 'parameters.count', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - }, - }; - const res = renderQueryStringParameters(def, {} as any); + it('should handle 2 anyOfs with reference correctly', () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + anyOf: [ + { $ref: '#/components/schemas/BasicAuth' }, + { $ref: '#/components/schemas/OAuth2' }, + ], + }, + }), + opts + ); - expect(res).to.be.ok; - expect(textOnly(res[0])).to.be.equal( - textOnly('parameters?: {page?: number; count?: number; }') - ); - }); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth | OAuth2;'); + }); + }); - it('should handle four elements in a dot notation', () => { - const def = { - type: 'object', - required: [], - queryParam: true, - properties: { - parameters_page: { - name: 'parameters.page', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - parameters_count: { - name: 'parameters.count', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - else_page: { - name: 'else.page', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - else_count: { - name: 'else.count', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - }, - }; - const res = renderQueryStringParameters(def, {} as any); + describe('oneOf', () => { + it('should handle 1 anyOf with reference correctly', () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + oneOf: [{ $ref: '#/components/schemas/BasicAuth' }], + }, + }), + opts + ); - expect(res).to.be.ok; - expect(res.length).to.eq(2); - expect(textOnly(res[0])).to.be.equal( - textOnly('parameters?: {page?: number; count?: number; }') - ); - expect(textOnly(res[1])).to.be.equal(textOnly('else?: {page?: number; count?: number; }')); - }); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth;'); + }); - it('crazy case #1', () => { - const def = { - type: 'object', - required: [], - queryParam: true, - properties: { - parameters_filter_countryName: { - name: 'parameters.filter.countryName', - in: 'query', - required: false, - type: 'string', - }, - parameters_filter_active: { - name: 'parameters.filter.active', - in: 'query', - required: false, - type: 'boolean', - }, - parameters_sortField: { - name: 'parameters.sortField', - in: 'query', - required: false, - type: 'string', - }, - parameters_sortDir: { - name: 'parameters.sortDir', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - enum: ['Undefined', 'Asc', 'Desc'], - fullEnum: { - Undefined: 0, - Asc: 1, - Desc: 2, - }, - }, - parameters_page: { - name: 'parameters.page', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - parameters_count: { - name: 'parameters.count', - in: 'query', - required: false, - type: 'integer', - format: 'int32', - }, - test: { - name: 'test', - in: 'query', - required: true, - type: 'integer', - format: 'int32', - }, - }, - }; - const res = renderQueryStringParameters(def, {} as any); + it('should handle 2 anyOfs with reference correctly', () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + oneOf: [ + { $ref: '#/components/schemas/BasicAuth' }, + { $ref: '#/components/schemas/OAuth2' }, + ], + }, + }), + opts + ); - expect(res).to.be.ok; - expect(res.length).to.eq(2); - expect(textOnly(res[0])).to.be.equal( - textOnly(`parameters?: { -filter?: { - countryName?: string; - active?: boolean; -} - sortField?: string; - sortDir?: number; - page?: number; - count?: number; -}`) - ); - expect(textOnly(res[1])).to.be.equal(textOnly('test: number;')); + expect(res).to.be.ok; + expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth | OAuth2;'); + }); + }); }); }); describe('renderComment', () => { - it('should render proper multiline comment', () => { - const comment = `Quite a lenghty comment -With at least two lines`; - const res = renderComment(comment); - - expect(res).to.be.equal(` /** - * Quite a lenghty comment - * With at least two lines - */`); - }); - it('should render proper multiline comment with trimming', () => { const comment = ` Quite a lenghty comment With at least two lines `; @@ -453,38 +397,41 @@ With at least two lines`; */`); }); - it('should render proper one-line comment', () => { - const comment = 'One liner'; - const res = renderComment(comment); - - expect(res).to.be.equal('// One liner'); - }); - - it('should render proper one-line comment with trimming', () => { - const comment = ' One liner '; - const res = renderComment(comment); - - expect(res).to.be.equal('// One liner'); - }); - - it('should handle null comment', () => { - const comment = null; - const res = renderComment(comment); - - expect(res).to.be.null; - }); - - it('should handle empty comment', () => { - const comment = ''; - const res = renderComment(comment); - - expect(res).to.be.null; - }); + const testCases = [ + { + comment: 'One liner', + expected: '// One liner', + }, + { + comment: ' One liner ', + expected: '// One liner', + }, + { + comment: null, + expected: null, + }, + { + comment: '', + expected: null, + }, + ]; + + for (const { comment, expected } of testCases) { + it(`should render proper comment for "${comment}"`, () => { + const res = renderComment(comment); + + expect(res).to.be.equal(expected); + }); + } }); -function textOnly(content: string) { - if (!content) { - return null; - } - return content.replace(/\s+/g, ''); +type ExtendedSchema = { + [key: string]: OA3.ReferenceObject | (OA31.SchemaObject & { [key: `x-${string}`]: any }); +}; +function prepareSchemas(schemas: ExtendedSchema) { + return getDocument({ + components: { + schemas: schemas as OA3.ComponentsObject['schemas'], + }, + }); } diff --git a/src/gen/js/genTypes.ts b/src/gen/js/genTypes.ts index 5a825af..c63a05f 100644 --- a/src/gen/js/genTypes.ts +++ b/src/gen/js/genTypes.ts @@ -1,243 +1,147 @@ -import { dset as set } from 'dset'; -import { join, uniq } from '../util'; -import { getParameterType } from './support'; -import type { IQueryDefinitions } from './models'; -import type { ApiSpec, ClientOptions } from '../../types'; - -export default function genTypes( - spec: ApiSpec, - queryDefinitions: IQueryDefinitions, - options: ClientOptions -) { - const lines = []; - join(lines, renderDefinitions(spec, queryDefinitions, options)); +import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; - return lines.join('\n'); -} +import { getTypeFromSchema } from './support'; +import type { ClientOptions } from '../../types'; -function renderDefinitions( - spec: ApiSpec, - queryDefinitions: IQueryDefinitions, - options: ClientOptions -): string[] { - const defs = unwrapDefinitions({ - ...(spec.definitions || {}), - ...queryDefinitions, - }); - const typeLines = []; - const docLines = []; - const nonGenericTypes = Object.keys(defs).filter((k) => k.indexOf('[') === -1); - const genericTypes = Object.keys(defs).filter((k) => k.indexOf('[') > -1); - const uniqGenericTypes = getDistinctGenericTypes(genericTypes); - - nonGenericTypes.forEach((name) => { - const def = defs[name]; - join(typeLines, renderTsType(name, def, options)); - }); - - uniqGenericTypes.forEach((name) => { - const realKey = genericTypes.filter((t) => t.indexOf(name) === 0).pop(); - const def = defs[realKey]; - const genericName = name + ''; - const typeToBeGeneric = realKey.substring(realKey.indexOf('[') + 1, realKey.indexOf(']')); - join(typeLines, renderTsType(genericName, def, options, typeToBeGeneric)); - }); - - return typeLines.concat(docLines); -} +/** + * Generates TypeScript types for the given OpenAPI 3 document. + * @returns String containing all TypeScript types in the document. + */ +export default function genTypes(spec: OA3.Document, options: ClientOptions): string { + const result: string[] = []; + + const schemaKeys = Object.keys(spec.components?.schemas || {}); + if (schemaKeys.length === 0) { + return ''; + } -function renderTsType(name, def, options: ClientOptions, typeToBeGeneric?: string) { - if (def.allOf) { - return renderTsInheritance(name, def.allOf, options); + for (const schemaName of schemaKeys) { + const schema = spec.components.schemas[schemaName]; + result.push(renderType(schemaName, schema, options)); } - if (!isSupportedDefType(def)) { - console.warn(`Unable to render ${name} ${def.type}, skipping.`); - return []; + + return result.join('\n'); +} + +function renderType( + name: string, + schema: OA3.ReferenceObject | OA3.SchemaObject, + options: ClientOptions +): string { + // This is an interesting case, because it is allowed but not likely to be used + // as it is just a reference to another schema object + if ('$ref' in schema) { + return `export type ${name} = ${schema.$ref.split('/').pop()};`; } - const lines = []; - if (def.description) { - lines.push(renderComment(def.description)); + const result: string[] = []; + if (schema.description) { + result.push(renderComment(schema.description)); } - if (def['x-enumNames']) { - lines.push(renderXEnumType(name, def)); - return lines; + if ('x-enumNames' in schema || 'x-enum-varnames' in schema) { + result.push(renderExtendedEnumType(name, schema)); + return result.join('\n'); + } + if ('enum' in schema) { + result.push(renderEnumType(name, schema)); + return result.join('\n'); } - if (def.enum) { - lines.push(renderEnumType(name, def)); - return lines; + if ('oneOf' in schema && schema.type !== 'object') { + result.push(renderOpenApi31Enum(name, schema)); + return result.join('\n'); } - lines.push(`export interface ${name} {`); - const required = def.required || []; - const props = Object.keys(def.properties || {}); - const requiredProps = props.filter((p) => !!~required.indexOf(p)); - const optionalProps = props.filter((p) => !~required.indexOf(p)); + const extensions = getTypeExtensions(schema); + result.push(`export interface ${name} ${extensions}{`); - if (def.queryParam) { - const res = renderQueryStringParameters(def, options); - join(lines, res); + if ('allOf' in schema) { + const mergedSchema = getMergedAllOfObjects(schema); + result.push(generateObjectTypeContents(mergedSchema, options)); } else { - const requiredPropLines = requiredProps - .map((prop) => renderTsTypeProp(prop, def.properties[prop], true, options, typeToBeGeneric)) - .reduce((a, b) => a.concat(b), []); - - const optionalPropLines = optionalProps - .filter((p) => !def.properties[p].readOnly) - .map((prop) => renderTsTypeProp(prop, def.properties[prop], false, options, typeToBeGeneric)) - .reduce((a, b) => a.concat(b), []); - - join(lines, requiredPropLines); - join(lines, optionalPropLines); + result.push(generateObjectTypeContents(schema, options)); } - lines.push('}\n'); - return lines; -} -/** - * Types coming from query models are different and they need to support nesting. Examples: - * @example - * 'parameters.filter.name': string, 'parameters.filter.query': string - * Will need to become: - * @example - * { - * parameters: { - * filter: { name: string, query: string } - * } - * } - */ -export function renderQueryStringParameters(def: any, options: ClientOptions): string[] { - const props = Object.keys(def.properties).map((e) => ({ - key: e, - name: def.properties[e].name, - parts: def.properties[e].name.split('.'), - })); - const objNotation = {}; - props.forEach((p) => set(objNotation, p.name, def.properties[p.key])); - - return processQueryStringParameter(objNotation, null, options); + return `${result.join('\n')}}\n`; } -function processQueryStringParameter( - props: any, - containerName: string | null, - options: ClientOptions -): string[] { - if (!props) { - return []; - } - if (!!props.in && !!props.name) { - const lastName = props.name.split('.').pop(); - return renderTsTypeProp(lastName, props, props.required, options); - } - const arr = Object.keys(props).map((p) => { - return processQueryStringParameter(props[p], p, options).join('\n'); - }); +function generateObjectTypeContents(schema: OA3.SchemaObject, options: ClientOptions) { + const result: string[] = []; + const required = schema.required || []; + const props = Object.keys(schema.properties || {}); - if (containerName) { - return [`${containerName}?: {`, ...arr, '}']; + for (const prop of props) { + const propDefinition = schema.properties[prop]; + const isRequired = !!~required.indexOf(prop); + result.push(renderTsTypeProp(prop, propDefinition, isRequired, options)); } - return arr; -} -/** Basically only object and (x-)enum types are supported */ -function isSupportedDefType(def: any) { - return def.type === 'object' || !!def['x-enumNames'] || !!def.enum; + return result.join('\n'); } -function renderXEnumType(name: string, def: any) { +/** + * NSwag (or OpenAPI Generator) can generate enums with custom names for each value. + * We support `x-enumNames` or `x-enum-varnames` for this feature. + */ +function renderExtendedEnumType(name: string, def: OA3.SchemaObject) { const isString = def.type === 'string'; let res = `export enum ${name} {\n`; - const enumNames = def['x-enumNames'] as string[]; - const enumValues = (def.enum as any[]).map((el) => (isString ? `"${el}"` : el)); + const enumNames: string[] = def['x-enumNames'] ?? def['x-enum-varnames']; + const enumValues = def.enum.map((el) => (isString ? `"${el}"` : el)); for (let index = 0; index < enumNames.length; index++) { res += ` ${enumNames[index]} = ${enumValues[index]},\n`; } - res += '}\n'; - return res; + return `${res}}\n`; } -function renderEnumType(name: string, def: any) { - if (def.fullEnum) { - const enumKeys = Object.keys(def.fullEnum).map((k) => ` ${k} = ${def.fullEnum[k]},`); - return `export enum ${name} { -${enumKeys.join('\n')} -}\n`; - } - - const values = (def.enum as any[]).map((v) => (typeof v === 'number' ? v : `'${v}'`)).join(' | '); +function renderEnumType(name: string, def: OA3.SchemaObject) { + const values = def.enum.map((v) => (typeof v === 'number' ? v : `"${v}"`)).join(' | '); return `export type ${name} = ${values};\n`; } -function renderTsInheritance(name: string, allOf: any[], options: ClientOptions) { - verifyAllOf(name, allOf); - const ref = allOf[0]; - const parentName = ref.$ref.split('/').pop(); - const lines = renderTsType(name, allOf[1], options); - const interfaceLineIndex = lines.findIndex((l) => l.indexOf('export interface') === 0); - if (interfaceLineIndex > -1) { - // Let's replace generic interface definition with more specific one with inheritance info - lines[interfaceLineIndex] = `export interface ${name} extends ${parentName} {`; - } - return lines; -} - -function renderTsTypeProp( - prop: string, - info: any, - required: boolean, - options: ClientOptions, - typeToBeGeneric?: string -): string[] { - const lines = []; - let type = getParameterType(info, options); - if (typeToBeGeneric && type.indexOf(typeToBeGeneric) === 0) { - type = type.replace(typeToBeGeneric, 'T'); - } - if (info.description) { - lines.push(renderComment(info.desciption)); +/** + * OpenApi 3.1 introduced a new way to define enums that we support here. + */ +function renderOpenApi31Enum(name: string, def: OA31.SchemaObject) { + let res = `export enum ${name} {\n`; + for (const v of def.oneOf) { + if ('const' in v) { + res += ` ${v.title} = ${typeof v.const === 'string' ? `"${v.const}"` : v.const},\n`; + } } - const req = required ? '' : '?'; - lines.push(` ${prop}${req}: ${type};`); - return lines; + return `${res}}\n`; } -function verifyAllOf(name: string, allOf: any[]) { - // Currently we interpret allOf as inheritance. Not strictly correct - // but seems to be how most model inheritance in Swagger and is consistent - // with other code generation tool - if (!allOf || allOf.length !== 2) { - console.log(allOf); - throw new Error( - `Json schema allOf '${name}' must have two elements to be treated as inheritance` - ); - } - const ref = allOf[0]; - if (!ref.$ref) { - throw new Error(`Json schema allOf '${name}' first element must be a $ref ${ref}`); - } -} - -function getDistinctGenericTypes(keys: string[]) { - const sanitizedKeys = keys.map((k) => k.substring(0, k.indexOf('['))); - return uniq(sanitizedKeys); -} +// function renderTsInheritance(name: string, allOf: any[], options: ClientOptions) { +// const ref = allOf[0]; +// const parentName = ref.$ref.split('/').pop(); +// const lines = renderTsType(name, allOf[1], options); +// const interfaceLineIndex = lines.findIndex((l) => l.indexOf('export interface') === 0); +// if (interfaceLineIndex > -1) { +// // Let's replace generic interface definition with more specific one with inheritance info +// lines[interfaceLineIndex] = `export interface ${name} extends ${parentName} {`; +// } +// return lines; +// } -function unwrapDefinitions(definitions: any) { - const result: any = {}; +function renderTsTypeProp( + prop: string, + definition: OA3.ReferenceObject | OA3.SchemaObject, + required: boolean, + options: ClientOptions +): string { + const lines: string[] = []; + const type = getTypeFromSchema(definition, options); - for (const definitionKey of Object.keys(definitions)) { - const def = definitions[definitionKey]; - if ('definitions' in def && typeof def.definitions === 'object') { - Object.assign(result, unwrapDefinitions(def.definitions)); - } - result[definitionKey] = def; + if ('description' in definition) { + lines.push(renderComment(definition.description)); } + const optionalMark = required ? '' : '?'; + lines.push(` ${prop}${optionalMark}: ${type};`); - return result; + return lines.join('\n'); } export function renderComment(comment: string | null) { @@ -253,3 +157,47 @@ export function renderComment(comment: string | null) { return ` /**\n${commentLines.map((line) => ` * ${line.trim()}`).join('\n')}\n */`; } + +function getTypeExtensions(schema: OA3.SchemaObject) { + if ('allOf' in schema) { + const refs = schema.allOf + .filter((v) => '$ref' in v) + .map((s: OA3.ReferenceObject) => s.$ref.split('/').pop()); + return `extends ${refs.join(', ')} `; + } + + return ''; +} + +function getMergedAllOfObjects(schema: OA3.SchemaObject) { + const subSchemas = schema.allOf.filter((v) => !('$ref' in v)); + + return deepMerge({}, ...subSchemas); +} + +function isObject(item: any): item is Record { + return item && typeof item === 'object' && !Array.isArray(item); +} +function deepMerge>(target: T, ...sources: Partial[]): T { + if (!sources.length) return target; + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + deepMerge(target[key], source[key]); + } else if (Array.isArray(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: source[key] }); + } else if (Array.isArray(target[key])) { + (target[key] as any[]) = Array.from(new Set([...target[key], ...source[key]])); + } + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return deepMerge(target, ...sources); +} diff --git a/src/gen/js/index.ts b/src/gen/js/index.ts index 8c99b63..8cfe356 100644 --- a/src/gen/js/index.ts +++ b/src/gen/js/index.ts @@ -1,15 +1,17 @@ +import type { OpenAPIV3 as OA3 } from 'openapi-types'; + import genOperations from './genOperations'; import genTypes from './genTypes'; import { saveFile, prepareOutputFilename } from '../util'; -import type { ApiOperation, ApiSpec, ClientOptions } from '../../types'; +import type { ApiOperation, ClientOptions } from '../../types'; export default async function genCode( - spec: ApiSpec, + spec: OA3.Document, operations: ApiOperation[], options: ClientOptions ): Promise { - let [fileContents, queryDefinitions] = await genOperations(spec, operations, options); - fileContents += genTypes(spec, queryDefinitions, options); + let fileContents = await genOperations(spec, operations, options); + fileContents += genTypes(spec, options); if (options.out) { const destFile = prepareOutputFilename(options.out); diff --git a/src/gen/js/models.ts b/src/gen/js/models.ts index 4755203..4f0094a 100644 --- a/src/gen/js/models.ts +++ b/src/gen/js/models.ts @@ -1,5 +1,3 @@ -import type { ApiOperationParam } from '../../types'; - export interface IApiOperation { returnType: string; method: string; @@ -25,19 +23,3 @@ export interface IServiceClient { baseUrl?: string; operations: IApiOperation[]; } - -export interface IQueryPropDefinition { - type: string; - format?: string; - required?: string[]; - properties?: { [key: string]: ApiOperationParam }; - enum?: any; - fullEnum?: any; - description?: string; - 'x-enumNames'?: string[]; - queryParam?: boolean; -} - -export interface IQueryDefinitions { - [key: string]: IQueryPropDefinition; -} diff --git a/src/gen/js/support.spec.ts b/src/gen/js/support.spec.ts index 41e099d..927a49c 100644 --- a/src/gen/js/support.spec.ts +++ b/src/gen/js/support.spec.ts @@ -77,23 +77,26 @@ describe('getParameterType', () => { expect(res).to.be.equal('SomeItem'); }); - describe('responses', () => { - it('generics', async () => { - const param: OA3.ParameterObject = { - name: 'query', - in: 'body', - required: false, - schema: { - $ref: '#/definitions/PagingAndSortingParameters[Item]', + it('inline enums', async () => { + const param: OA3.ParameterObject = { + name: 'Roles', + in: 'query', + schema: { + type: 'array', + items: { + enum: ['Admin', 'User', 'Guest'], + type: 'string', }, - }; - const options = {}; + }, + }; + const options = {}; - const res = getParameterType(param, options); + const res = getParameterType(param, options); - expect(res).to.be.equal('PagingAndSortingParameters'); - }); + expect(res).to.be.equal(`("Admin" | "User" | "Guest")[]`); + }); + describe('responses', () => { it('string', async () => { const param: OA3.ParameterObject = { name: 'title', diff --git a/src/gen/js/support.ts b/src/gen/js/support.ts index d53ebf6..d763776 100644 --- a/src/gen/js/support.ts +++ b/src/gen/js/support.ts @@ -26,7 +26,7 @@ export function getParameterType( return getTypeFromSchema(param.schema, options); } -function getTypeFromSchema( +export function getTypeFromSchema( schema: OA3.SchemaObject | OA3.ReferenceObject, options: Partial ): string { @@ -36,8 +36,7 @@ function getTypeFromSchema( return unknownType; } if ('$ref' in schema) { - const type = schema.$ref.split('/').pop(); - return handleGenerics(type || unknownType); + return schema.$ref.split('/').pop(); } if (schema.type === 'array') { @@ -55,6 +54,9 @@ function getTypeFromSchema( } return unknownType; } + if ('enum' in schema) { + return `(${schema.enum.map((v) => JSON.stringify(v)).join(' | ')})`; + } if (schema.type === 'integer' || schema.type === 'number') { return 'number'; } @@ -69,13 +71,3 @@ function getTypeFromSchema( } return unknownType; } - -function handleGenerics(type: string) { - if (!/^\w+\[\w+\]/.test(type)) { - return type; - } - - // const fixedType = type.replace(/\[/g, '<').replace(/\]/g, '>'); - const parts = type.split('['); - return parts.join('<').replace(/\]/g, '>'); -} diff --git a/src/gen/util.ts b/src/gen/util.ts index dd51dd0..ca36850 100644 --- a/src/gen/util.ts +++ b/src/gen/util.ts @@ -1,8 +1,7 @@ import { type Stats, lstatSync, mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; - -import type { ApiOperationResponse } from '../types'; +import type { ApiOperation } from '../types'; export function exists(filePath: string): Stats { try { @@ -25,7 +24,7 @@ export function saveFile(filePath: string, contents: string) { }); } -export function groupOperationsByGroupName(operations) { +export function groupOperationsByGroupName(operations: ApiOperation[]) { if (!operations) { return {}; } @@ -38,11 +37,6 @@ export function groupOperationsByGroupName(operations) { }, {}); } -export function join(parent: string[], child: string[]): string[] { - parent.push.apply(parent, child); - return parent; -} - /** * Operations in OpenAPI can have multiple responses, but * we are interested in the one that is the most common for @@ -84,14 +78,6 @@ export function prepareOutputFilename(out: string | null): string { return `${out.replace(/[\\]/i, '/')}.ts`; } -export function uniq(arr?: T[]) { - if (!arr) { - return []; - } - - return [...new Set(arr)]; -} - export function orderBy(arr: T[] | null | undefined, key: string) { if (!arr) { return []; diff --git a/src/index.spec.ts b/src/index.spec.ts index e3154c2..661031c 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -3,7 +3,8 @@ import fs from 'node:fs'; import * as fetch from 'node-fetch'; import { Response } from 'node-fetch'; import sinon from 'sinon'; -import { runCodeGenerator, applyConfigFile } from './index'; + +import { runCodeGenerator, applyConfigFile } from './'; describe('runCodeGenerator', () => { afterEach(sinon.restore); @@ -109,7 +110,7 @@ describe('runCodeGenerator', () => { const parameters = { config: './test/sample-config.json', }; - const conf = await applyConfigFile(parameters as any); + const conf = await applyConfigFile(parameters); expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://google.pl'); expect(conf.src).to.be.equal( @@ -123,7 +124,7 @@ describe('runCodeGenerator', () => { baseUrl: 'https://wp.pl', src: './test/petstore-v3.yml', }; - const conf = await applyConfigFile(parameters as any); + const conf = await applyConfigFile(parameters); expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://wp.pl'); expect(conf.src).to.be.equal('./test/petstore-v3.yml'); diff --git a/src/index.ts b/src/index.ts index fec75af..ecd946f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,10 +41,10 @@ function gen(spec: OA3.Document, options: ClientOptions): Promise { return genJsCode(spec, operations, options); } -export async function applyConfigFile(options: FullAppOptions): Promise { +export async function applyConfigFile(options: Partial): Promise { try { if (!options.config) { - return options; + return options as ClientOptions; } const configUrl = options.config; diff --git a/src/swagger/operations.spec.ts b/src/swagger/operations.spec.ts index 033ff41..c0c0f5d 100644 --- a/src/swagger/operations.spec.ts +++ b/src/swagger/operations.spec.ts @@ -1,14 +1,11 @@ import { expect } from 'chai'; import { getOperations } from './operations'; import { loadSpecDocument } from '../utils/documentLoader'; +import { getDocument } from '../utils'; describe('getPathOperation', () => { it('should handle empty operation list', () => { - const spec = { - swagger: '2.0', - paths: {}, - definitions: {}, - }; + const spec = getDocument(); const res = getOperations(spec as any); @@ -17,14 +14,12 @@ describe('getPathOperation', () => { }); it('should handle one operation list', () => { - const spec = { - swagger: '2.0', + const spec = getDocument({ paths: { '/api/heartbeat': { get: { tags: ['System'], operationId: 'ApiHeartbeatGet', - produces: ['application/json'], responses: { '200': { description: 'Service is available.', @@ -33,16 +28,12 @@ describe('getPathOperation', () => { }, }, }, - definitions: {}, - contentTypes: [], - accepts: [], - }; + }); const res = getOperations(spec as any); const validResp = [ { - accepts: ['application/json'], contentTypes: [], group: 'System', id: 'ApiHeartbeatGet', @@ -50,42 +41,12 @@ describe('getPathOperation', () => { parameters: [], path: '/api/heartbeat', responses: [{ code: '200', description: 'Service is available.' }], - security: undefined, tags: ['System'], }, ]; expect(res).to.be.eql(validResp); }); - it('should handle additional content types', () => { - const spec = { - swagger: '2.0', - paths: { - '/api/heartbeat': { - post: { - tags: ['System'], - operationId: 'ApiHeartbeatGet', - produces: ['application/json'], - consumes: ['application/x-www-form-urlencoded'], - responses: { - '200': { - description: 'Service is available.', - }, - }, - }, - }, - }, - definitions: {}, - contentTypes: [], - accepts: [], - }; - - const res = getOperations(spec as any); - - expect(res).to.be.ok; - expect(res[0].contentTypes).to.be.eql(['application/x-www-form-urlencoded']); - }); - it('should parse operations from spec [PetStore Example]', async () => { const path = `${__dirname}/../../test/petstore-v3.yml`; const spec = await loadSpecDocument(path); diff --git a/src/swagger/operations.ts b/src/swagger/operations.ts index 55daf2b..e53a62e 100644 --- a/src/swagger/operations.ts +++ b/src/swagger/operations.ts @@ -1,3 +1,4 @@ +import type { OpenAPIV3 as OA3 } from 'openapi-types'; import type { ApiOperation, ApiOperationResponse, @@ -25,69 +26,56 @@ const SUPPORTED_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'p * { "method": "POST", "path": "/api/heartbeat", ... }, * ] */ -export function getOperations(spec: ApiSpec): ApiOperation[] { +export function getOperations(spec: OA3.Document): ApiOperation[] { return getPaths(spec).reduce( (ops, pathInfo) => ops.concat(getPathOperations(pathInfo, spec)), [] ); } -function getPaths(spec: ApiSpec): object[] { +function getPaths(spec: OA3.Document): OA3.PathItemObject[] { return Object.keys(spec.paths || {}).map((path) => Object.assign({ path }, spec.paths[path])); } -function getPathOperations(pathInfo, spec): ApiOperation[] { +function getPathOperations(pathInfo: OA3.PathItemObject, spec: OA3.Document): ApiOperation[] { return Object.keys(pathInfo) .filter((key) => !!~SUPPORTED_METHODS.indexOf(key)) .map((method) => getPathOperation(method as HttpMethod, pathInfo, spec)); } -function inheritPathParams(op, spec, pathInfo) { +function inheritPathParams(op: ApiOperation, spec: OA3.Document, pathInfo: OA3.PathItemObject) { const pathParams = spec.paths[pathInfo.path].parameters; if (pathParams) { - pathParams.forEach((pathParam) => { + for (const pathParam of pathParams) { if (!op.parameters.some((p) => p.name === pathParam.name && p.in === pathParam.in)) { op.parameters.push(Object.assign({}, pathParam)); } - }); + } } } -function getPathOperation(method: HttpMethod, pathInfo, spec: ApiSpec): ApiOperation { - const op = Object.assign({ method, path: pathInfo.path, parameters: [] }, pathInfo[method]); - op.id = op.operationId; +function getPathOperation( + method: HttpMethod, + pathInfo: OA3.PathItemObject, + spec: OA3.Document +): ApiOperation { + const op: ApiOperation = Object.assign( + { method, path: pathInfo.path, parameters: [] }, + pathInfo[method] + ); // if there's no explicit operationId given, create one based on the method and path - if (!op.id) { - op.id = method + pathInfo.path; - op.id = op.id.replace(/[\/{(?\/{)\-]([^{.])/g, (_, m) => m.toUpperCase()); - op.id = op.id.replace(/[\/}\-]/g, ''); + if (!op.operationId) { + op.operationId = method + pathInfo.path; + op.operationId = op.operationId.replace(/[\/{(?\/{)\-]([^{.])/g, (_, m) => m.toUpperCase()); + op.operationId = op.operationId.replace(/[\/}\-]/g, ''); } inheritPathParams(op, spec, pathInfo); op.group = getOperationGroupName(op); - delete op.operationId; - op.responses = getOperationResponses(op); - op.security = getOperationSecurity(op, spec); - - const operation: any = op; - if (operation.consumes) { - operation.contentTypes = operation.consumes; - } - if (operation.produces) { - operation.accepts = operation.produces; - } - delete operation.consumes; - delete operation.produces; - if (!op.contentTypes || !op.contentTypes.length) { - op.contentTypes = spec.contentTypes.slice(); - } - if (!op.accepts || !op.accepts.length) { - op.accepts = spec.accepts.slice(); - } - return op as ApiOperation; + return op; } function getOperationGroupName(op: any): string { @@ -95,29 +83,3 @@ function getOperationGroupName(op: any): string { name = name.replace(/[^$_a-z0-9]+/gi, ''); return name.replace(/^[0-9]+/m, ''); } - -function getOperationResponses(op: any): ApiOperationResponse[] { - return Object.keys(op.responses || {}).map((code) => { - const info = op.responses[code]; - info.code = code; - return info; - }); -} - -function getOperationSecurity(op: any, spec: any): ApiOperationSecurity[] { - let security; - - if (op.security && op.security.length) { - security = op.security; - } else if (spec.security && spec.security.length) { - security = spec.security; - } else { - return; - } - - return security.map((def) => { - const id = Object.keys(def)[0]; - const scopes = def[id].length ? def[id] : undefined; - return { id, scopes }; - }); -} diff --git a/src/types.ts b/src/types.ts index cc02e9e..10c4726 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import type { OpenAPIV3 as OA3 } from 'openapi-types'; + export interface ClientOptions { /** * Path or URL to the Swagger specification file (JSON or YAML). @@ -11,8 +13,6 @@ export interface ClientOptions { baseUrl?: string; preferAny?: boolean; servicePrefix?: string; - /** Generate models for query string instead list of parameters */ - queryModels?: boolean; /** How date should be handled. It does not do any special serialization */ dateFormat?: DateSupport; // 'luxon', 'momentjs', etc } @@ -26,18 +26,6 @@ export type Template = 'axios' | 'fetch' | 'ng1' | 'ng2' | 'swr-axios' | 'xior'; export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch'; export type DateSupport = 'string' | 'Date'; // 'luxon', 'momentjs', etc -export interface ApiOperationParam extends ApiOperationParamBase { - name: string; - in: 'header' | 'path' | 'query' | 'body' | 'formData'; - description: string; - required: boolean; - readonly?: boolean; - allowEmptyValue: boolean; - schema: object; - 'x-nullable'?: boolean; - 'x-schema'?: object; -} - type CollectionFormat = 'csv' | 'ssv' | 'tsv' | 'pipes' | 'multi'; export interface ApiOperationParamBase { @@ -77,6 +65,15 @@ export interface ApiOperationParamGroups { body?: any; } +/** + * Local type that represent Operation as understood by Swaggie + **/ +export interface ApiOperation extends OA3.OperationObject { + method: HttpMethod; + path: string; + group: string; +} + export interface ApiOperationResponse { code: string; description: string; diff --git a/src/utils/documentLoader.ts b/src/utils/documentLoader.ts index d13e4db..dc1dd4d 100644 --- a/src/utils/documentLoader.ts +++ b/src/utils/documentLoader.ts @@ -3,13 +3,6 @@ import fs from 'node:fs'; import fetch from 'node-fetch'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; -export interface SpecOptions { - /** - * A base ref string to ignore when expanding ref dependencies e.g. '#/definitions/' - */ - ignoreRefType?: string; -} - export async function loadSpecDocument(src: string | object): Promise { if (typeof src === 'string') { return await loadFile(src); diff --git a/src/utils/index.ts b/src/utils/index.ts index bcba1fe..08d3b03 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './utils'; export * from './documentLoader'; +export * from './test.utils'; diff --git a/src/utils/test.utils.ts b/src/utils/test.utils.ts new file mode 100644 index 0000000..47a9d95 --- /dev/null +++ b/src/utils/test.utils.ts @@ -0,0 +1,29 @@ +import type { OpenAPIV3 as OA3 } from 'openapi-types'; +import type { ClientOptions } from '../types'; + +/** + * Returns a valid OpenAPI 3.0 document with the minimal required fields. + * And it allows to easily override any of the fields. + */ +export function getDocument(document: Partial = {}): OA3.Document { + return { + openapi: '3.0.0', + paths: {}, + info: { + title: 'Test', + version: '1.0.0', + }, + components: {}, + + ...document, + }; +} + +export function getClientOptions(opts: Partial = {}): ClientOptions { + return { + src: 'http://example.com/swagger.json', + out: 'output.ts', + template: 'xior', + ...opts, + }; +} diff --git a/test/ci-test.config.json b/test/ci-test.config.json index 9cd0151..7cde0d4 100644 --- a/test/ci-test.config.json +++ b/test/ci-test.config.json @@ -4,6 +4,5 @@ "src": "./test/petstore-v3.json", "template": "axios", "preferAny": true, - "queryModels": true, "dateFormat": "string" } diff --git a/test/index.d.ts b/test/index.d.ts index b5dbda1..caf0863 100644 --- a/test/index.d.ts +++ b/test/index.d.ts @@ -1,6 +1,8 @@ -import { ApiSpec, ClientOptions, FullAppOptions } from './types'; +import type { OpenAPIV3 } from 'openapi-types'; +import type { ClientOptions, FullAppOptions } from '../src/types'; + /** Runs whole code generation process. @returns generated code */ export declare function runCodeGenerator(options: FullAppOptions): Promise; /** Validates if the spec is correct and if is supported */ -export declare function verifySpec(spec: ApiSpec): Promise; +export declare function verifySpec(spec: OpenAPIV3.Document): Promise; export declare function applyConfigFile(options: FullAppOptions): Promise; diff --git a/test/sample-config.json b/test/sample-config.json index e2f683d..01521dc 100644 --- a/test/sample-config.json +++ b/test/sample-config.json @@ -6,6 +6,5 @@ "baseUrl": "https://google.pl", "preferAny": true, "servicePrefix": "Test", - "queryModels": true, "dateFormat": "string" } diff --git a/test/snapshots.spec.ts b/test/snapshots.spec.ts index d761c0e..e2d79d3 100644 --- a/test/snapshots.spec.ts +++ b/test/snapshots.spec.ts @@ -1,12 +1,13 @@ import { expect } from 'chai'; -import fs from 'fs'; +import fs from 'node:fs'; + import { runCodeGenerator } from '../src/index'; import type { FullAppOptions, Template } from '../src/types'; const templates: Template[] = ['axios', 'xior', 'swr-axios', 'fetch', 'ng1', 'ng2']; describe('petstore snapshots', () => { - templates.forEach((template) => { + for (const template of templates) { it(`should match existing ${template} snapshot`, async () => { const snapshotFile = `./test/snapshots/${template}.ts`; const parameters: FullAppOptions = { @@ -26,5 +27,5 @@ describe('petstore snapshots', () => { expect(existingSnapshot).to.equal(generatedCode); } }); - }); + } }); diff --git a/yarn.lock b/yarn.lock index 7a28acb..7077750 100644 --- a/yarn.lock +++ b/yarn.lock @@ -346,11 +346,6 @@ diff@^5.2.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== -dset@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.3.tgz#c194147f159841148e8e34ca41f638556d9542d2" - integrity sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ== - eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" From 72a129afbb34dd16a885b23347ad8403a2735586 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Tue, 2 Jul 2024 12:00:17 +0200 Subject: [PATCH 05/27] feat: support oneOf / anyOf for type generation impr: better support for Wallaby test runner impr: easier comparing generated types in unit tests by custom chai assertion --- .mocharc.json | 14 ++- src/gen/js/genTypes.spec.ts | 183 +++++++++++++++++------------------- src/gen/js/genTypes.ts | 66 ++++++++----- test/chai-extensions.ts | 25 +++++ test/chai.d.ts | 8 ++ test/test-setup.ts | 1 + tsconfig.json | 4 +- wallaby.js | 10 +- 8 files changed, 185 insertions(+), 126 deletions(-) create mode 100644 test/chai-extensions.ts create mode 100644 test/chai.d.ts create mode 100644 test/test-setup.ts diff --git a/.mocharc.json b/.mocharc.json index 27817e1..57f2300 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,5 +1,13 @@ { - "extension": ["js", "ts"], - "require": ["sucrase/register"], - "spec": ["src/**/*.spec.ts", "test/**/*.spec.ts"] + "extension": [ + "js", + "ts" + ], + "require": [ + "sucrase/register", + "./test/test-setup.ts" + ], + "spec": [ + "src/**/*.spec.ts" + ] } diff --git a/src/gen/js/genTypes.spec.ts b/src/gen/js/genTypes.spec.ts index 607473b..ecee861 100644 --- a/src/gen/js/genTypes.spec.ts +++ b/src/gen/js/genTypes.spec.ts @@ -32,11 +32,10 @@ describe('genTypes', () => { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal( - `export type A = B; -export interface B { -}` + expect(res).to.equalWI( + ` +export type A = B; +export interface B {}` ); }); @@ -58,9 +57,9 @@ export interface B { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal( - `export type SimpleEnum = 0 | 1; + expect(res).to.equalWI( + ` +export type SimpleEnum = 0 | 1; // Feature is activated or not export type StringEnum = "Active" | "Disabled";` @@ -92,9 +91,9 @@ export type StringEnum = "Active" | "Disabled";` opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal( - `export enum XEnums { + expect(res).to.equalWI( + ` +export enum XEnums { High = 2, Medium = 1, Low = 0, @@ -140,9 +139,9 @@ export enum XEnumsString { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal( - `export enum Priority { + expect(res).to.equalWI( + ` +export enum Priority { High = 2, Medium = 1, Low = 0, @@ -174,8 +173,7 @@ export enum Size { // {} as any // ); - // expect(res).to.be.ok; - // expect(res.trim()).to.be.equal(`export enum SomeEnum { + // expect(res).to.be.equal(`export enum SomeEnum { // Active = 0, // Disabled = 1, // }`); @@ -204,13 +202,14 @@ export enum Size { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export interface AuthenticationData { + expect(res).to.equalWI(` +export interface AuthenticationData { login?: string; - password?: string;} + password?: string; +} -export interface Empty { -}`); +export interface Empty {} +`); }); it('should handle obj with required fields', () => { @@ -240,11 +239,12 @@ export interface Empty { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export interface AuthenticationData { + expect(res).to.equalWI(` +export interface AuthenticationData { login: string; password: string; - rememberMe?: boolean;}`); + rememberMe?: boolean; +}`); }); }); @@ -270,9 +270,10 @@ export interface Empty { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be.equal(`export interface AuthenticationData extends BasicAuth { - rememberMe?: boolean;}`); + expect(res).to.equalWI(` +export interface AuthenticationData extends BasicAuth { + rememberMe?: boolean; +}`); }); it('should handle many allOf correctly', () => { @@ -305,11 +306,11 @@ export interface Empty { opts ); - expect(res).to.be.ok; - expect(res.trim()).to.be - .equal(`export interface AuthenticationData extends LoginPart, PasswordPart { + expect(res).to.equalWI(` +export interface AuthenticationData extends LoginPart, PasswordPart { rememberMe: boolean; - signForSpam?: boolean;}`); + signForSpam?: boolean; +}`); }); }); @@ -317,71 +318,63 @@ export interface Empty { // We just list all the types in the union. This is close enough to the truth // and should be convenient for the end user of Swaggie. - describe('anyOf', () => { - it('should handle 1 anyOf with reference correctly', () => { - const res = genTypes( - prepareSchemas({ - AuthenticationData: { - anyOf: [{ $ref: '#/components/schemas/BasicAuth' }], - }, - }), - opts - ); - - expect(res).to.be.ok; - expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth;'); - }); - - it('should handle 2 anyOfs with reference correctly', () => { - const res = genTypes( - prepareSchemas({ - AuthenticationData: { - anyOf: [ - { $ref: '#/components/schemas/BasicAuth' }, - { $ref: '#/components/schemas/OAuth2' }, - ], - }, - }), - opts - ); - - expect(res).to.be.ok; - expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth | OAuth2;'); - }); - }); - - describe('oneOf', () => { - it('should handle 1 anyOf with reference correctly', () => { - const res = genTypes( - prepareSchemas({ - AuthenticationData: { - oneOf: [{ $ref: '#/components/schemas/BasicAuth' }], - }, - }), - opts - ); - - expect(res).to.be.ok; - expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth;'); - }); - - it('should handle 2 anyOfs with reference correctly', () => { - const res = genTypes( - prepareSchemas({ - AuthenticationData: { - oneOf: [ - { $ref: '#/components/schemas/BasicAuth' }, - { $ref: '#/components/schemas/OAuth2' }, - ], - }, - }), - opts - ); - - expect(res).to.be.ok; - expect(res.trim()).to.be.equal('export type AuthenticationData = BasicAuth | OAuth2;'); + for (const type of ['anyOf', 'oneOf']) { + describe(type, () => { + it(`should handle 1 ${type} with reference correctly`, () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + [type]: [{ $ref: '#/components/schemas/BasicAuth' }], + }, + }), + opts + ); + + expect(res).to.equalWI('export type AuthenticationData = BasicAuth;'); + }); + + it(`should handle 2 of ${type} with reference correctly`, () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + [type]: [ + { $ref: '#/components/schemas/BasicAuth' }, + { $ref: '#/components/schemas/OAuth2' }, + ], + }, + }), + opts + ); + + expect(res).to.equalWI('export type AuthenticationData = BasicAuth | OAuth2;'); + }); + + it(`should handle ${type} with reference and schema correctly`, () => { + const res = genTypes( + prepareSchemas({ + AuthenticationData: { + [type]: [ + { $ref: '#/components/schemas/BasicAuth' }, + { + type: 'object', + properties: { + token: { + type: 'string', + }, + }, + }, + ], + }, + }), + opts + ); + + expect(res).to.equalWI( + 'export type AuthenticationData = BasicAuth | { token?: string; };' + ); + }); }); - }); + } }); }); diff --git a/src/gen/js/genTypes.ts b/src/gen/js/genTypes.ts index c63a05f..43cd315 100644 --- a/src/gen/js/genTypes.ts +++ b/src/gen/js/genTypes.ts @@ -47,24 +47,44 @@ function renderType( result.push(renderEnumType(name, schema)); return result.join('\n'); } - if ('oneOf' in schema && schema.type !== 'object') { + + // OpenAPI 3.1 enums support. We need to check if the schema is an object and has a oneOf property + if ('oneOf' in schema && schema.type !== 'object' && schema.type) { result.push(renderOpenApi31Enum(name, schema)); return result.join('\n'); } - const extensions = getTypeExtensions(schema); - result.push(`export interface ${name} ${extensions}{`); - if ('allOf' in schema) { - const mergedSchema = getMergedAllOfObjects(schema); + const types = getCompositeTypes(schema); + const extensions = types ? `extends ${types.join(', ')} ` : ''; + result.push(`export interface ${name} ${extensions}{`); + + const mergedSchema = getMergedCompositeObjects(schema); result.push(generateObjectTypeContents(mergedSchema, options)); + } else if ('oneOf' in schema || 'anyOf' in schema) { + const typeDefinition = getTypesFromAnyOrOneOf(schema, options); + result.push(`export type ${name} = ${typeDefinition};`); + + return `${result.join('\n')}\n`; } else { + result.push(`export interface ${name} {`); result.push(generateObjectTypeContents(schema, options)); } return `${result.join('\n')}}\n`; } +function getTypesFromAnyOrOneOf(schema: OA3.SchemaObject, options: ClientOptions) { + const types = getCompositeTypes(schema); + const mergedSchema = getMergedCompositeObjects(schema); + const typeContents = generateObjectTypeContents(mergedSchema, options); + if (typeContents) { + types.push(`{ ${typeContents} }`); + } + + return types.join(' | '); +} + function generateObjectTypeContents(schema: OA3.SchemaObject, options: ClientOptions) { const result: string[] = []; const required = schema.required || []; @@ -95,6 +115,9 @@ function renderExtendedEnumType(name: string, def: OA3.SchemaObject) { return `${res}}\n`; } +/** + * Render simple enum types (just a union of values) + */ function renderEnumType(name: string, def: OA3.SchemaObject) { const values = def.enum.map((v) => (typeof v === 'number' ? v : `"${v}"`)).join(' | '); return `export type ${name} = ${values};\n`; @@ -114,18 +137,6 @@ function renderOpenApi31Enum(name: string, def: OA31.SchemaObject) { return `${res}}\n`; } -// function renderTsInheritance(name: string, allOf: any[], options: ClientOptions) { -// const ref = allOf[0]; -// const parentName = ref.$ref.split('/').pop(); -// const lines = renderTsType(name, allOf[1], options); -// const interfaceLineIndex = lines.findIndex((l) => l.indexOf('export interface') === 0); -// if (interfaceLineIndex > -1) { -// // Let's replace generic interface definition with more specific one with inheritance info -// lines[interfaceLineIndex] = `export interface ${name} extends ${parentName} {`; -// } -// return lines; -// } - function renderTsTypeProp( prop: string, definition: OA3.ReferenceObject | OA3.SchemaObject, @@ -158,19 +169,26 @@ export function renderComment(comment: string | null) { return ` /**\n${commentLines.map((line) => ` * ${line.trim()}`).join('\n')}\n */`; } -function getTypeExtensions(schema: OA3.SchemaObject) { - if ('allOf' in schema) { - const refs = schema.allOf +/** + * Returns a string with the types that the given schema extends. + * It uses the `allOf`, `oneOf` or `anyOf` properties to determine the types. + * If the schema has no composite types, it returns an empty string. + * If there are more than one composite types, it analyzes only the first one. + */ +function getCompositeTypes(schema: OA3.SchemaObject) { + const composite = schema.allOf || schema.oneOf || schema.anyOf || []; + if (composite) { + return composite .filter((v) => '$ref' in v) .map((s: OA3.ReferenceObject) => s.$ref.split('/').pop()); - return `extends ${refs.join(', ')} `; } - return ''; + return []; } -function getMergedAllOfObjects(schema: OA3.SchemaObject) { - const subSchemas = schema.allOf.filter((v) => !('$ref' in v)); +function getMergedCompositeObjects(schema: OA3.SchemaObject) { + const composite = schema.allOf || schema.oneOf || schema.anyOf || []; + const subSchemas = composite.filter((v) => !('$ref' in v)); return deepMerge({}, ...subSchemas); } diff --git a/test/chai-extensions.ts b/test/chai-extensions.ts new file mode 100644 index 0000000..1315bdc --- /dev/null +++ b/test/chai-extensions.ts @@ -0,0 +1,25 @@ +import chaiType from 'chai'; + +chaiType.use((chai, utils) => { + chai.Assertion.addMethod('equalWI', function (expected: string) { + const actual = this._obj; + + const normalizedActual = normalizeWhitespace(actual); + const normalizedExpected = normalizeWhitespace(expected); + + this.assert( + normalizedActual === normalizedExpected, + 'expected #{this} to equal #{exp} ignoring whitespace', + 'expected #{this} to not equal #{exp} ignoring whitespace', + expected, + actual, + true + ); + }); +}); + +/** + * It will get rid of all whitespaces and trim the string, so that we can + * compare strings without worrying about whitespaces. + */ +const normalizeWhitespace = (str: string) => str.replace(/\s+/g, '').trim(); diff --git a/test/chai.d.ts b/test/chai.d.ts new file mode 100644 index 0000000..c190951 --- /dev/null +++ b/test/chai.d.ts @@ -0,0 +1,8 @@ +declare namespace Chai { + interface Assertion { + /** + * A way to compare strings without worrying about whitespaces + */ + equalWI(expected: string): void; + } +} diff --git a/test/test-setup.ts b/test/test-setup.ts new file mode 100644 index 0000000..ccc4e97 --- /dev/null +++ b/test/test-setup.ts @@ -0,0 +1 @@ +import './chai-extensions'; diff --git a/tsconfig.json b/tsconfig.json index 6ebcefd..3ce171b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", + "rootDir": "./", "target": "es2017", "module": "commonjs", "declaration": true, @@ -11,5 +11,5 @@ "newLine": "lf", "skipLibCheck": true }, - "exclude": ["node_modules", "dist", "test", "coverage", "**/*.spec.ts"] + "exclude": ["node_modules", "dist", "coverage", "test/snapshots"] } diff --git a/wallaby.js b/wallaby.js index d823400..3284514 100644 --- a/wallaby.js +++ b/wallaby.js @@ -1,12 +1,18 @@ module.exports = () => ({ - files: ['src/**/*.ts', '!src/**/*.spec.ts'], + files: ['test/chai-extensions.ts', 'src/**/*.ts', '!src/**/*.spec.ts'], + require: [], tests: ['src/**/*.spec.ts'], env: { type: 'node', }, + delays: { + run: 1000 + }, testFramework: 'mocha', - autoDetect: false + autoDetect: false, + + setup: () => { require('./test/chai-extensions'); } }); From c3aa213136276fbe3ba15b91de461ba177572f10 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Tue, 2 Jul 2024 23:31:07 +0200 Subject: [PATCH 06/27] feat: basic functionality for generating operations is there fix: wallaby now handles all tests correctly fix: all tests are passing now --- src/gen/js/createBarrel.ts | 8 +- src/gen/js/genOperations.spec.ts | 364 +++++++++---------------------- src/gen/js/genOperations.ts | 97 ++++---- src/gen/js/index.ts | 10 +- src/gen/js/models.ts | 25 --- src/gen/util.spec.ts | 90 ++++---- src/gen/util.ts | 9 +- src/index.ts | 3 +- src/swagger/operations.spec.ts | 156 ++++++++++--- src/swagger/operations.ts | 60 +++-- src/utils/test.utils.ts | 4 + wallaby.js | 8 +- 12 files changed, 395 insertions(+), 439 deletions(-) delete mode 100644 src/gen/js/models.ts diff --git a/src/gen/js/createBarrel.ts b/src/gen/js/createBarrel.ts index b6927e4..fe57d88 100644 --- a/src/gen/js/createBarrel.ts +++ b/src/gen/js/createBarrel.ts @@ -1,8 +1,12 @@ import { camel } from 'case'; -import type { ClientOptions } from '../../types'; +import type { ApiOperation, ClientOptions } from '../../types'; import { renderFile } from '../templateManager'; -export function generateBarrelFile(clients: any[], clientOptions: ClientOptions) { +type ClientGroups = { + [key: string]: ApiOperation[]; +}; + +export function generateBarrelFile(clients: ClientGroups, clientOptions: ClientOptions) { const files = []; for (const name in clients) { diff --git a/src/gen/js/genOperations.spec.ts b/src/gen/js/genOperations.spec.ts index f68322f..be1e42f 100644 --- a/src/gen/js/genOperations.spec.ts +++ b/src/gen/js/genOperations.spec.ts @@ -1,244 +1,108 @@ import { expect } from 'chai'; + import { prepareOperations, fixDuplicateOperations, getOperationName } from './genOperations'; import type { ApiOperation } from '../../types'; -import { orderBy } from '../util'; +import { getClientOptions } from '../../utils'; describe('prepareOperations', () => { - // TODO: For now we ignore custom content types - // it(`operation's content type should be put in header`, () => { - // const ops = [ - // { - // id: 'getPetById', - // summary: 'Find pet by ID', - // description: 'Returns a single pet', - // method: 'get', - // path: '/pet/{petId}', - // parameters: [], - // responses: [], - // group: null, - // accepts: ['application/json'], - // contentTypes: ['application/x-www-form-urlencoded'], - // }, - // ] as ApiOperation[]; - - // const res = prepareOperations(ops, {} as any); - - // expect(res).to.be.ok; - // expect(res[0].headers).to.be.eql([ - // { - // name: 'contentType', - // originalName: 'Content-Type', - // type: 'string', - // optional: false, - // }, - // ]); - // }); + const opts = getClientOptions(); - it(`operation's empty header list should be handled correctly`, () => { - const ops: ApiOperation[] = [ - { - operationId: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [], - responses: {}, - group: null, - }, - ]; - - const [res] = prepareOperations(ops, {} as any); - - expect(res).to.be.ok; - expect(res[0].headers).to.be.eql([]); - }); - - it(`operation's content type should be put in header + more headers in parameters`, () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - name: 'Some-Other', - in: 'header', - description: '', - required: true, - allowEmptyValue: true, - schema: { - type: 'string', + describe('parameters', () => { + it('should prepare parameter types for use in templates', () => { + const ops: ApiOperation[] = [ + { + operationId: 'getPetById', + method: 'get', + path: '/pet/{petId}', + parameters: [ + { + name: 'Org-ID', + in: 'header', + required: true, + schema: { + type: 'string', + }, }, - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: ['application/x-www-form-urlencoded'], - }, - ] as unknown as ApiOperation[]; + { + name: 'OrgType', + in: 'query', + required: false, + allowEmptyValue: true, + schema: { + type: 'string', + }, + }, + { + name: 'petId', + in: 'path', + required: false, + schema: { + type: 'number', + format: 'int64', + }, + }, + ], + responses: {}, + group: null, + }, + ]; - const [res] = prepareOperations(ops, {} as any); + const [res] = prepareOperations(ops, opts); - expect(res).to.be.ok; - expect(res[0].headers[0]).to.be.deep.include( - { - name: 'someOther', - originalName: 'Some-Other', + expect(res.name).to.equal('getPetById'); + expect(res.method).to.equal('GET'); + expect(res.body).to.be.undefined; + expect(res.returnType).to.equal('unknown'); + + expect(res.headers.pop()).to.deep.include({ + name: 'orgID', + originalName: 'Org-ID', type: 'string', optional: false, - } - // TODO: For now we ignore custom content types - // { - // name: 'contentType', - // originalName: 'Content-Type', - // type: 'string', - // optional: false, - // value: 'application/x-www-form-urlencoded', - // }, - ); - }); - - it(`operation's param should be used instead of operation's default content types`, () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - name: 'Content-Type', - in: 'header', - description: '', - required: true, - allowEmptyValue: true, - schema: { - type: 'string', - }, - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: ['application/x-www-form-urlencoded'], - }, - ] as unknown as ApiOperation[]; - - const [res] = prepareOperations(ops, {} as any); + }); - expect(res).to.be.ok; - expect(res[0].headers).to.be.ok; - const orderedHeaders = orderBy(res[0].headers, 'name'); - expect(orderedHeaders[0]).to.deep.include({ - name: 'contentType', - originalName: 'Content-Type', - type: 'string', - optional: false, + expect(res.query.pop()).to.deep.include({ + name: 'orgType', + originalName: 'OrgType', + type: 'string', + optional: true, + }); + + expect(res.pathParams.pop()).to.deep.include({ + name: 'petId', + originalName: 'petId', + type: 'number', + optional: true, + }); }); }); - - describe('generate query model', () => { - const op = { - id: 'Pet_GetPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - name: 'FirstParameter', - in: 'query', - description: '', - required: true, - type: 'string', - }, - { - name: 'SecondParameter', - in: 'query', - description: '', - required: true, - type: 'string', - }, - { - name: 'Filter.AnotherParameter', - in: 'query', - description: '', - required: true, - type: 'string', - }, - ], - responses: [], - group: 'Pet', - accepts: ['application/json'], - contentTypes: [], - } as unknown as ApiOperation; - }); - - it('formdata array param should be serialized correctly as array', () => { - const ops = [ - { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', - method: 'get', - path: '/pet/{petId}', - parameters: [ - { - type: 'array', - name: 'files', - in: 'formData', - collectionFormat: 'multi', - 'x-nullable': true, - items: { - type: 'file', - }, - }, - ], - responses: [], - group: null, - accepts: ['application/json'], - contentTypes: ['application/x-www-form-urlencoded'], - }, - ] as unknown as ApiOperation[]; - - const [res] = prepareOperations(ops, {} as any); - - expect(res).to.be.ok; - expect(res[0]?.parameters[0]).to.be.ok; - expect(res[0].parameters[0].originalName).to.be.equal('files'); - expect(res[0].parameters[0].original.type).to.be.equal('array'); - }); }); describe('fixDuplicateOperations', () => { - it('handle empty list', () => { - const ops = []; - - const res = fixDuplicateOperations(ops); - - expect(res).to.be.eql([]); - }); + const testCases = [ + { input: [], expected: [] }, + { input: null, expected: null }, + { input: undefined, expected: undefined }, + ]; + for (const { input, expected } of testCases) { + it(`should handle ${input} as input`, () => { + const res = fixDuplicateOperations(input); + + expect(res).to.deep.eq(expected); + }); + } - it(`handle list with 1 item only`, () => { - const ops = [ + it('handle list with 1 operation only', () => { + const ops: ApiOperation[] = [ { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', + operationId: 'getPetById', method: 'get', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: ['application/x-www-form-urlencoded'], }, - ] as ApiOperation[]; + ]; const res = fixDuplicateOperations(ops); @@ -250,8 +114,6 @@ describe('fixDuplicateOperations', () => { const ops: ApiOperation[] = [ { operationId: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', method: 'get', path: '/pet/{petId}', parameters: [], @@ -260,8 +122,6 @@ describe('fixDuplicateOperations', () => { }, { operationId: 'somethingElse', - summary: 'Random', - description: 'Random', method: 'get', path: '/pet/{petId}', parameters: [], @@ -276,88 +136,74 @@ describe('fixDuplicateOperations', () => { expect(res).to.be.deep.equal(ops); }); - it('handle 2 operations with the same id', () => { + it('handle 2 operations with the same operationId', () => { const ops: ApiOperation[] = [ { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', + operationId: 'getPetById', method: 'get', path: '/pet/{petId}', parameters: [], + responses: {}, group: null, }, { - id: 'getPetById', - summary: 'Random', - description: 'Random', + operationId: 'getPetById', method: 'post', path: '/pet/{petId}', parameters: [], + responses: {}, group: null, }, ]; const res = fixDuplicateOperations(ops); - expect(res[1].id).not.to.be.equal(res[0].id); + expect(res[1].operationId).not.to.be.equal(res[0].operationId); }); // TODO: If someone wants to adjust code to fix this issue, then please go ahead :) /* - it(`handle 3 operations with the same id even after fix`, () => { + it(`handle 3 operations with the same operationId even after fix`, () => { const ops = [ { - id: 'getPetById', - summary: 'Find pet by ID', - description: 'Returns a single pet', + operationId: 'getPetById', method: 'get', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: [], }, { - id: 'getPetById', - summary: 'Random', - description: 'Random', + operationId: 'getPetById', method: 'post', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: [], }, { - id: 'getPetById1', - summary: 'Random', - description: 'Random', + operationId: 'getPetById1', method: 'post', path: '/pet/{petId}', parameters: [], - responses: [], + responses: {}, group: null, - accepts: ['application/json'], - contentTypes: [], }, ] as ApiOperation[]; const res = fixDuplicateOperations(ops); - console.log('Ops', ops.map(e => e.id)); - console.log('Res', res.map(e => e.id)); + console.log('Ops', ops.map(e => e.operationId)); + console.log('Res', res.map(e => e.operationId)); - expect(res[0].id).not.to.be.equal(res[1].id); - expect(res[1].id).not.to.be.equal(res[2].id); + expect(res[0].operationId).not.to.be.equal(res[1].operationId); + expect(res[1].operationId).not.to.be.equal(res[2].operationId); }); */ }); describe('getOperationName', () => { - [ + const testCases = [ { input: { opId: 'test', group: null }, expected: 'test' }, { input: { opId: 'test', group: '' }, expected: 'test' }, { input: { opId: null, group: 'group' }, expected: '' }, @@ -368,11 +214,13 @@ describe('getOperationName', () => { input: { opId: 'Test_GetPetStory', group: 'Test' }, expected: 'getPetStory', }, - ].forEach((el) => { - it(`should handle ${JSON.stringify(el.input)}`, () => { - const res = getOperationName(el.input.opId, el.input.group); + ]; - expect(res).to.be.equal(el.expected); + for (const { input, expected } of testCases) { + it(`should handle ${JSON.stringify(input)}`, () => { + const res = getOperationName(input.opId, input.group); + + expect(res).to.be.equal(expected); }); - }); + } }); diff --git a/src/gen/js/genOperations.ts b/src/gen/js/genOperations.ts index 0057ede..5ce8511 100644 --- a/src/gen/js/genOperations.ts +++ b/src/gen/js/genOperations.ts @@ -3,17 +3,17 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { getParameterType } from './support'; import { groupOperationsByGroupName, getBestResponse, orderBy } from '../util'; -import type { IServiceClient, IApiOperation, IOperationParam } from './models'; import { generateBarrelFile } from './createBarrel'; import { renderFile } from '../templateManager'; import type { ApiOperation, ClientOptions } from '../../types'; import { escapeReservedWords } from '../../utils'; +import { getOperations } from '../../swagger'; export default async function genOperations( spec: OA3.Document, - operations: ApiOperation[], options: ClientOptions ): Promise { + const operations = getOperations(spec); const groups = groupOperationsByGroupName(operations); let result = renderFile('baseClient.ejs', { @@ -40,9 +40,9 @@ export default async function genOperations( function prepareClient( name: string, - operations: OA3.OperationObject[], + operations: ApiOperation[], options: ClientOptions -): IServiceClient { +): ClientData { const preparedOperations = prepareOperations(operations, options); return { @@ -54,38 +54,38 @@ function prepareClient( } export function prepareOperations( - operations: OA3.OperationObject[], + operations: ApiOperation[], options: ClientOptions -): IApiOperation[] { +): IOperation[] { const ops = fixDuplicateOperations(operations); return ops.map((op) => { - const response = getBestResponse(op); - const respType = getParameterType(response, options); + const responseObject = getBestResponse(op); + const returnType = getParameterType(responseObject, options); - const queryParams = getParams(op.parameters, options, ['query']); - const params = getParams(op.parameters, options); + const queryParams = getParams(op.parameters as OA3.ParameterObject[], options, ['query']); + const params = getParams(op.parameters as OA3.ParameterObject[], options); return { - returnType: respType, + returnType, method: op.method.toUpperCase(), name: getOperationName(op.operationId, op.group), url: op.path, parameters: params, query: queryParams, - formData: getParams(op.parameters, options, ['formData']), - pathParams: getParams(op.parameters, options, ['path']), - body: getParams(op.parameters, options, ['body']).pop(), - headers: getHeaders(op, options), + pathParams: getParams(op.parameters as OA3.ParameterObject[], options, ['path']), + body: op.requestBody as OA3.RequestBodyObject, + headers: getParams(op.parameters as OA3.ParameterObject[], options, ['header']), }; }); } /** - * We will add numbers to the duplicated operation names to avoid breaking code - * @param operations + * Let's add numbers to the duplicated operation names to avoid breaking code. + * Duplicated operation names are not allowed by the OpenAPI spec, but in the real world + * it can happen very easily and we need to handle it gracefully. */ -export function fixDuplicateOperations(operations: OA3.OperationObject[]): OA3.OperationObject[] { +export function fixDuplicateOperations(operations: ApiOperation[]): ApiOperation[] { if (!operations || operations.length < 2) { return operations; } @@ -118,30 +118,8 @@ export function getOperationName(opId: string | null, group?: string | null) { return camel(opId.replace(`${group}_`, '')); } -function getHeaders(op: OA3.OperationObject, options: ClientOptions): IOperationParam[] { - const headersFromParams = getParams(op.parameters, options, ['header']); - // TODO: At some point there may be need for a new param to add implicitly default content types - // TODO: At this time content-type support was not essential to move forward with this functionality - // It needs to be reviewed - - // if ( - // op.contentTypes.length > 0 && - // headersFromParams.filter((p) => p.originalName.toLowerCase() === 'content-type').length === 0 - // ) { - // headersFromParams.push({ - // name: 'contentType', - // optional: false, - // originalName: 'Content-Type', - // type: 'string', - // value: op.contentTypes.join(', '), - // }); - // } - - return headersFromParams; -} - function getParams( - params: ApiOperationParam[], + params: OA3.ParameterObject[], options: ClientOptions, where?: string[] ): IOperationParam[] { @@ -150,17 +128,12 @@ function getParams( } return params - .filter((p) => !where || where.indexOf(p.in) > -1) + .filter((p) => !where || where.includes(p.in)) .map((p) => ({ originalName: p.name, name: getParamName(p.name), type: getParameterType(p, options), - optional: - p.required === undefined || p.required === null - ? p['x-nullable'] === undefined || p['x-nullable'] === null - ? true - : !!p['x-nullable'] - : !p.required, + optional: p.required === undefined || p.required === null ? true : !p.required, original: p, })); } @@ -168,7 +141,7 @@ function getParams( export function renderOperationGroup( group: any[], func: any, - spec: ApiSpec, + spec: OA3.Document, options: ClientOptions ): string[] { return group.map((op) => func.call(this, spec, op, options)).reduce((a, b) => a.concat(b)); @@ -185,3 +158,29 @@ export function getParamName(name: string): string { .join('_') ); } + +interface ClientData { + clientName: string; + camelCaseName: string; + operations: IOperation[]; + baseUrl: string; +} + +interface IOperation { + returnType: string; + method: string; + name: string; + url: string; + parameters: IOperationParam[]; + query: IOperationParam[]; + pathParams: IOperationParam[]; + body: OA3.RequestBodyObject; + headers: IOperationParam[]; +} + +interface IOperationParam { + originalName: string; + name: string; + type: string; + optional: boolean; +} diff --git a/src/gen/js/index.ts b/src/gen/js/index.ts index 8cfe356..f72e2df 100644 --- a/src/gen/js/index.ts +++ b/src/gen/js/index.ts @@ -3,14 +3,10 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; import genOperations from './genOperations'; import genTypes from './genTypes'; import { saveFile, prepareOutputFilename } from '../util'; -import type { ApiOperation, ClientOptions } from '../../types'; +import type { ClientOptions } from '../../types'; -export default async function genCode( - spec: OA3.Document, - operations: ApiOperation[], - options: ClientOptions -): Promise { - let fileContents = await genOperations(spec, operations, options); +export default async function genCode(spec: OA3.Document, options: ClientOptions): Promise { + let fileContents = await genOperations(spec, options); fileContents += genTypes(spec, options); if (options.out) { diff --git a/src/gen/js/models.ts b/src/gen/js/models.ts deleted file mode 100644 index 4f0094a..0000000 --- a/src/gen/js/models.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface IApiOperation { - returnType: string; - method: string; - name: string; - url: string; - body: object | null | undefined; - parameters: IOperationParam[]; - headers: IOperationParam[]; -} - -export interface IOperationParam { - name: string; - originalName: string; - type: string; - optional: boolean; - value?: string; - original: ApiOperationParam; -} - -export interface IServiceClient { - clientName: string; - camelCaseName: string; - baseUrl?: string; - operations: IApiOperation[]; -} diff --git a/src/gen/util.spec.ts b/src/gen/util.spec.ts index 9302bea..41ee11e 100644 --- a/src/gen/util.spec.ts +++ b/src/gen/util.spec.ts @@ -1,40 +1,40 @@ import { expect } from 'chai'; -import { groupOperationsByGroupName, getBestResponse, prepareOutputFilename } from './util'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; -describe('groupOperationsByGroupName', () => { - it('handles null', async () => { - const def = null; - - const res = groupOperationsByGroupName(def); - - expect(res).to.be.eql({}); - }); - - it('handles empty array', async () => { - const def = []; +import { groupOperationsByGroupName, getBestResponse, prepareOutputFilename } from './util'; +import type { ApiOperation } from '../types'; - const res = groupOperationsByGroupName(def); +describe('groupOperationsByGroupName', () => { + const testCases = [ + { input: [], expected: {} }, + { input: null, expected: {} }, + { input: undefined, expected: {} }, + ]; + for (const { input, expected } of testCases) { + it(`should handle ${input} as input`, async () => { + const res = groupOperationsByGroupName(input); - expect(res).to.be.eql({}); - }); + expect(res).to.deep.equal(expected); + }); + } it('handles single operation', async () => { - const def = [ + const def: ApiOperation[] = [ { - consumes: [], - id: 'HealthCheck_PerformAllChecks', - method: 'GET', + operationId: 'HealthCheck_PerformAllChecks', + method: 'get', group: 'HealthCheck', + path: '/healthcheck', parameters: [ { in: 'query', name: 'token', required: false, - type: 'string', + schema: { + type: 'string', + }, }, ], - produces: [], responses: { '200': { description: 'Success', @@ -52,21 +52,22 @@ describe('groupOperationsByGroupName', () => { }); it('handles two different operations and the same group', async () => { - const def = [ + const def: ApiOperation[] = [ { - consumes: [], - id: 'HealthCheck_PerformAllChecks', - method: 'GET', + operationId: 'HealthCheck_PerformAllChecks', + method: 'get', group: 'HealthCheck', + path: '/healthcheck', parameters: [ { in: 'query', name: 'token', required: false, - type: 'string', + schema: { + type: 'string', + }, }, ], - produces: [], responses: { '200': { description: 'Success', @@ -75,19 +76,20 @@ describe('groupOperationsByGroupName', () => { tags: ['HealthCheck'], }, { - consumes: [], - id: 'HealthCheck_SomethingElse', - method: 'GET', + operationId: 'HealthCheck_SomethingElse', + method: 'post', group: 'HealthCheck', + path: '/healthcheck', parameters: [ { in: 'query', name: 'token', required: false, - type: 'string', + schema: { + type: 'string', + }, }, ], - produces: [], responses: { '200': { description: 'Success', @@ -105,21 +107,22 @@ describe('groupOperationsByGroupName', () => { }); it('handles two different operations and different groups', async () => { - const def = [ + const def: ApiOperation[] = [ { - consumes: [], - id: 'HealthCheck_PerformAllChecks', - method: 'GET', + operationId: 'HealthCheck_PerformAllChecks', + method: 'get', group: 'HealthCheck', + path: '/healthcheck', parameters: [ { in: 'query', name: 'token', required: false, - type: 'string', + schema: { + type: 'string', + }, }, ], - produces: [], responses: { '200': { description: 'Success', @@ -128,19 +131,20 @@ describe('groupOperationsByGroupName', () => { tags: ['HealthCheck'], }, { - consumes: [], - id: 'Illness_SomethingElse', - method: 'GET', + operationId: 'Illness_SomethingElse', + method: 'get', group: 'Illness', + path: '/illness', parameters: [ { in: 'query', name: 'token', required: false, - type: 'string', + schema: { + type: 'string', + }, }, ], - produces: [], responses: { '200': { description: 'Success', diff --git a/src/gen/util.ts b/src/gen/util.ts index ca36850..32bf9be 100644 --- a/src/gen/util.ts +++ b/src/gen/util.ts @@ -1,6 +1,7 @@ import { type Stats, lstatSync, mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; + import type { ApiOperation } from '../types'; export function exists(filePath: string): Stats { @@ -24,11 +25,17 @@ export function saveFile(filePath: string, contents: string) { }); } +/** + * Operations list contains tags, which can be used to group them. + * The grouping allows us to generate multiple client classes dedicated + * to a specific group of operations. + */ export function groupOperationsByGroupName(operations: ApiOperation[]) { if (!operations) { return {}; } - return operations.reduce((groups, op) => { + + return operations.reduce<{ [key: string]: ApiOperation[] }>((groups, op) => { if (!groups[op.group]) { groups[op.group] = []; } diff --git a/src/index.ts b/src/index.ts index ecd946f..f4d2dd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,8 +37,7 @@ function verifyOptions(options: FullAppOptions) { function gen(spec: OA3.Document, options: ClientOptions): Promise { loadAllTemplateFiles(options.template || 'axios'); - const operations = getOperations(spec); - return genJsCode(spec, operations, options); + return genJsCode(spec, options); } export async function applyConfigFile(options: Partial): Promise { diff --git a/src/swagger/operations.spec.ts b/src/swagger/operations.spec.ts index c0c0f5d..42b6912 100644 --- a/src/swagger/operations.spec.ts +++ b/src/swagger/operations.spec.ts @@ -1,15 +1,12 @@ import { expect } from 'chai'; import { getOperations } from './operations'; -import { loadSpecDocument } from '../utils/documentLoader'; import { getDocument } from '../utils'; +import type { ApiOperation } from '../types'; -describe('getPathOperation', () => { +describe('getOperations', () => { it('should handle empty operation list', () => { - const spec = getDocument(); + const res = getOperations(getDocument()); - const res = getOperations(spec as any); - - expect(res).to.be.ok; expect(res.length).to.eq(0); }); @@ -30,43 +27,140 @@ describe('getPathOperation', () => { }, }); - const res = getOperations(spec as any); + const res = getOperations(spec); - const validResp = [ + const validResp: ApiOperation[] = [ { - contentTypes: [], group: 'System', - id: 'ApiHeartbeatGet', + operationId: 'ApiHeartbeatGet', method: 'get', parameters: [], path: '/api/heartbeat', - responses: [{ code: '200', description: 'Service is available.' }], + responses: { 200: { description: 'Service is available.' } }, tags: ['System'], }, ]; - expect(res).to.be.eql(validResp); + expect(res).to.deep.equal(validResp); }); - it('should parse operations from spec [PetStore Example]', async () => { - const path = `${__dirname}/../../test/petstore-v3.yml`; - const spec = await loadSpecDocument(path); - const operations = getOperations(spec); - expect(operations).to.be.ok; - expect(operations.length).to.eq(3); + it('should handle empty operationId or tags', () => { + const spec = getDocument({ + paths: { + '/api/heartbeat': {}, + '/api/pokemon': { + get: { + tags: ['Pokemon'], + operationId: null, + responses: { + '200': { $ref: '#/components/responses/PokemonList' }, + }, + }, + post: { + tags: [], + operationId: null, + responses: {}, + }, + patch: { + operationId: 'pokePatch', + responses: {}, + }, + }, + }, + }); - const listPets = operations.find((op) => op.id === 'listPets'); - expect(listPets).to.be.ok; - expect(listPets?.method).to.be.equal('get'); - expect(listPets?.path).to.be.equal('/pets'); - expect(listPets?.tags).to.be.ok; - expect(listPets?.tags?.[0]).to.be.equal('pets'); - expect(listPets?.responses).to.be.ok; - expect(listPets?.responses.length).to.eq(2); + const res = getOperations(spec); - const res200 = listPets?.responses.find((res) => res.code === '200'); - expect(res200).to.be.ok; - expect(res200?.headers['x-next'].type).to.be.equal('string'); - const resDefault = listPets?.responses.find((res) => res.code === 'default'); - expect(resDefault).to.be.ok; + const validResp: ApiOperation[] = [ + { + group: 'Pokemon', + // id will be generated as sanitized method + path when it's not defined + operationId: 'getApiPokemon', + method: 'get', + parameters: [], + path: '/api/pokemon', + responses: { 200: { $ref: '#/components/responses/PokemonList' } }, + tags: ['Pokemon'], + }, + { + group: 'default', + // id will be generated as sanitized method + path when it's not defined + operationId: 'postApiPokemon', + method: 'post', + parameters: [], + path: '/api/pokemon', + responses: {}, + tags: [], + }, + { + group: 'default', + operationId: 'pokePatch', + method: 'patch', + parameters: [], + path: '/api/pokemon', + responses: {}, + }, + ]; + expect(res).to.deep.equal(validResp); }); + + // TODO: Test path inheritance + // it('should handle inheritance of parameters', () => { + // const spec = getDocument({ + // paths: { + // '/api/heartbeat': {}, + // '/api/pokemon': { + // get: { + // tags: ['Pokemon'], + // operationId: null, + // responses: { + // '200': { $ref: '#/components/responses/PokemonList' }, + // }, + // }, + // post: { + // tags: [], + // operationId: null, + // responses: {}, + // }, + // patch: { + // operationId: 'pokePatch', + // responses: {}, + // }, + // }, + // }, + // }); + + // const res = getOperations(spec); + + // const validResp: ApiOperation[] = [ + // { + // group: 'Pokemon', + // // id will be generated as sanitized method + path when it's not defined + // operationId: 'getApiPokemon', + // method: 'get', + // parameters: [], + // path: '/api/pokemon', + // responses: { 200: { $ref: '#/components/responses/PokemonList' } }, + // tags: ['Pokemon'], + // }, + // { + // group: 'default', + // // id will be generated as sanitized method + path when it's not defined + // operationId: 'postApiPokemon', + // method: 'post', + // parameters: [], + // path: '/api/pokemon', + // responses: {}, + // tags: [], + // }, + // { + // group: 'default', + // operationId: 'pokePatch', + // method: 'patch', + // parameters: [], + // path: '/api/pokemon', + // responses: {}, + // }, + // ]; + // expect(res).to.deep.equal(validResp); + // }); }); diff --git a/src/swagger/operations.ts b/src/swagger/operations.ts index e53a62e..a482cd2 100644 --- a/src/swagger/operations.ts +++ b/src/swagger/operations.ts @@ -1,10 +1,5 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import type { - ApiOperation, - ApiOperationResponse, - ApiOperationSecurity, - HttpMethod, -} from '../types'; +import type { ApiOperation, HttpMethod } from '../types'; const SUPPORTED_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch']; @@ -28,27 +23,50 @@ const SUPPORTED_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'p */ export function getOperations(spec: OA3.Document): ApiOperation[] { return getPaths(spec).reduce( - (ops, pathInfo) => ops.concat(getPathOperations(pathInfo, spec)), + (acc, el) => acc.concat(getPathOperations(el, spec)), [] ); } -function getPaths(spec: OA3.Document): OA3.PathItemObject[] { +/** + * This method converts dictionary-alike path definition to path array + * @example + * "/url": { ... } -> [ { "path": "/url", ... } ] + */ +function getPaths(spec: OA3.Document): PathInfo[] { return Object.keys(spec.paths || {}).map((path) => Object.assign({ path }, spec.paths[path])); } -function getPathOperations(pathInfo: OA3.PathItemObject, spec: OA3.Document): ApiOperation[] { +function getPathOperations(pathInfo: PathInfo, spec: OA3.Document): ApiOperation[] { return Object.keys(pathInfo) .filter((key) => !!~SUPPORTED_METHODS.indexOf(key)) .map((method) => getPathOperation(method as HttpMethod, pathInfo, spec)); } -function inheritPathParams(op: ApiOperation, spec: OA3.Document, pathInfo: OA3.PathItemObject) { +/** + * Parameters can be defined on the path level and should be inherited by all operations + * contained in the path. + */ +function inheritPathParams(op: ApiOperation, spec: OA3.Document, pathInfo: PathInfo) { const pathParams = spec.paths[pathInfo.path].parameters; if (pathParams) { for (const pathParam of pathParams) { - if (!op.parameters.some((p) => p.name === pathParam.name && p.in === pathParam.in)) { - op.parameters.push(Object.assign({}, pathParam)); + if ('$ref' in pathParam) { + if ( + !op.parameters + .filter((p) => '$ref' in p) + .some((p: OA3.ReferenceObject) => p.$ref === pathParam.$ref) + ) { + op.parameters.push(Object.assign({}, pathParam)); + } + } else { + if ( + !op.parameters + .filter((p) => 'name' in p) + .some((p: OA3.ParameterObject) => p.name === pathParam.name && p.in === pathParam.in) + ) { + op.parameters.push(Object.assign({}, pathParam)); + } } } } @@ -56,30 +74,32 @@ function inheritPathParams(op: ApiOperation, spec: OA3.Document, pathInfo: OA3.P function getPathOperation( method: HttpMethod, - pathInfo: OA3.PathItemObject, + pathInfo: PathInfo, spec: OA3.Document ): ApiOperation { const op: ApiOperation = Object.assign( - { method, path: pathInfo.path, parameters: [] }, + { method, path: pathInfo.path, parameters: [], group: getOperationGroupName(pathInfo[method]) }, pathInfo[method] ); // if there's no explicit operationId given, create one based on the method and path if (!op.operationId) { - op.operationId = method + pathInfo.path; - op.operationId = op.operationId.replace(/[\/{(?\/{)\-]([^{.])/g, (_, m) => m.toUpperCase()); - op.operationId = op.operationId.replace(/[\/}\-]/g, ''); + op.operationId = (method + pathInfo.path) + .replace(/[\/{(?\/{)\-]([^{.])/g, (_, m) => m.toUpperCase()) + .replace(/[\/}\-]/g, ''); } inheritPathParams(op, spec, pathInfo); - op.group = getOperationGroupName(op); - return op; } -function getOperationGroupName(op: any): string { +function getOperationGroupName(op: OA3.OperationObject): string { let name = op.tags?.length ? op.tags[0] : 'default'; name = name.replace(/[^$_a-z0-9]+/gi, ''); return name.replace(/^[0-9]+/m, ''); } + +interface PathInfo extends OA3.PathItemObject { + path: string; +} diff --git a/src/utils/test.utils.ts b/src/utils/test.utils.ts index 47a9d95..b22cdcb 100644 --- a/src/utils/test.utils.ts +++ b/src/utils/test.utils.ts @@ -19,6 +19,10 @@ export function getDocument(document: Partial = {}): OA3.Document }; } +/** + * Returns a valid ClientOptions object with the minimal required fields. + * And it allows to easily override any of the fields. + */ export function getClientOptions(opts: Partial = {}): ClientOptions { return { src: 'http://example.com/swagger.json', diff --git a/wallaby.js b/wallaby.js index 3284514..61601a2 100644 --- a/wallaby.js +++ b/wallaby.js @@ -1,5 +1,11 @@ module.exports = () => ({ - files: ['test/chai-extensions.ts', 'src/**/*.ts', '!src/**/*.spec.ts'], + files: [ + 'test/chai-extensions.ts', + 'src/**/*.ts', + 'test/*.json', + 'test/*.yml', + '!src/**/*.spec.ts' + ], require: [], tests: ['src/**/*.spec.ts'], From 035019e726fdc5a114095c6a20f73d5354331177 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 3 Jul 2024 13:26:44 +0200 Subject: [PATCH 07/27] impr: better support for array types impr: better test coverage for the types supported chore: remove obsolete code and comments --- src/gen/js/genTypes.spec.ts | 91 +++++++++++++++++++- src/gen/js/genTypes.ts | 18 +++- src/gen/js/support.spec.ts | 149 ++++++++++++++++++++++++++++++-- src/gen/js/support.ts | 38 ++++++-- src/index.spec.ts | 56 ++++++------ src/index.ts | 5 +- src/schema.ts | 41 --------- src/swagger/operations.spec.ts | 153 ++++++++++++++++++++------------- src/swagger/operations.ts | 1 + src/utils/documentLoader.ts | 94 -------------------- wallaby.js | 1 + 11 files changed, 412 insertions(+), 235 deletions(-) delete mode 100644 src/schema.ts diff --git a/src/gen/js/genTypes.spec.ts b/src/gen/js/genTypes.spec.ts index ecee861..26be6c1 100644 --- a/src/gen/js/genTypes.spec.ts +++ b/src/gen/js/genTypes.spec.ts @@ -248,6 +248,95 @@ export interface AuthenticationData { }); }); + describe('arrays', () => { + it('should handle simple array cases', () => { + const res = genTypes( + prepareSchemas({ + StringArray: { + type: 'array', + items: { + type: 'string', + }, + }, + EmptyArray: { + type: 'array', + items: {}, + }, + ObjectArray: { + type: 'array', + items: { + $ref: '#/components/schemas/UserViewModel', + }, + }, + }), + opts + ); + + expect(res).to.equalWI(` +export type StringArray = string[]; +export type EmptyArray = unknown[]; +export type ObjectArray = UserViewModel[]; +`); + }); + + it('should handle different array types as properties', () => { + const res = genTypes( + prepareSchemas({ + ComplexObject: { + type: 'object', + required: ['roles', 'ids'], + properties: { + roles: { + type: 'array', + items: { + type: 'string', + }, + }, + ids: { + type: 'array', + items: { + format: 'int32', + type: 'number', + }, + }, + inlineGroups: { + type: 'array', + items: { + type: 'object', + required: ['id'], + properties: { + name: { + type: 'string', + }, + id: { + format: 'int32', + type: 'number', + }, + }, + }, + }, + groups: { + type: 'array', + items: { + $ref: '#/components/schemas/Group', + }, + }, + }, + }, + }), + opts + ); + + expect(res).to.equalWI(` +export interface ComplexObject { + roles: string[]; + ids: number[]; + inlineGroups?: { name?: string; id: number; }[]; + groups?: Group[]; +}`); + }); + }); + describe('inheritance', () => { describe('allOf', () => { it('should handle 2 allOf correctly (most common case)', () => { @@ -419,7 +508,7 @@ describe('renderComment', () => { }); type ExtendedSchema = { - [key: string]: OA3.ReferenceObject | (OA31.SchemaObject & { [key: `x-${string}`]: any }); + [key: string]: OA3.ReferenceObject | (OA31.SchemaObject & { [key: `x-${string}`]: object }); }; function prepareSchemas(schemas: ExtendedSchema) { return getDocument({ diff --git a/src/gen/js/genTypes.ts b/src/gen/js/genTypes.ts index 43cd315..4956229 100644 --- a/src/gen/js/genTypes.ts +++ b/src/gen/js/genTypes.ts @@ -66,6 +66,11 @@ function renderType( result.push(`export type ${name} = ${typeDefinition};`); return `${result.join('\n')}\n`; + } else if (schema.type === 'array') { + // This case is quite rare but is definitely possible that a schema definition is + // an array of something. In this case it's just a type reference + result.push(`export type ${name} = ${generateItemsType(schema.items, options)}[];`); + return result.join('\n'); } else { result.push(`export interface ${name} {`); result.push(generateObjectTypeContents(schema, options)); @@ -99,6 +104,17 @@ function generateObjectTypeContents(schema: OA3.SchemaObject, options: ClientOpt return result.join('\n'); } +function generateItemsType(schema: OA3.ReferenceObject | OA3.SchemaObject, options: ClientOptions) { + const fallbackType = options.preferAny ? 'any' : 'unknown'; + + if ('$ref' in schema) { + return schema.$ref.split('/').pop() ?? fallbackType; + } + + // Schema object is not supported at the moment, but it can be added if needed + return schema.type ?? fallbackType; +} + /** * NSwag (or OpenAPI Generator) can generate enums with custom names for each value. * We support `x-enumNames` or `x-enum-varnames` for this feature. @@ -193,7 +209,7 @@ function getMergedCompositeObjects(schema: OA3.SchemaObject) { return deepMerge({}, ...subSchemas); } -function isObject(item: any): item is Record { +function isObject(item?: object): item is Record { return item && typeof item === 'object' && !Array.isArray(item); } function deepMerge>(target: T, ...sources: Partial[]): T { diff --git a/src/gen/js/support.spec.ts b/src/gen/js/support.spec.ts index 927a49c..5d46e3b 100644 --- a/src/gen/js/support.spec.ts +++ b/src/gen/js/support.spec.ts @@ -1,11 +1,18 @@ import { expect } from 'chai'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import type { ClientOptions } from '../../types'; -import { getParameterType } from './support'; +import { getParameterType, getTypeFromSchema } from './support'; +import { getClientOptions } from '../../utils'; describe('getParameterType', () => { describe('empty cases', () => { - const testCases = [ + type TestCase = { + param?: OA3.ParameterObject | OA3.MediaTypeObject | null | any; + options: Partial; + expected: string; + }; + + const testCases: TestCase[] = [ { param: null, options: { preferAny: true }, expected: 'any' }, { param: undefined, options: {}, expected: 'unknown' }, { param: {}, options: {}, expected: 'unknown' }, @@ -20,7 +27,7 @@ describe('getParameterType', () => { for (const { param, options, expected } of testCases) { it(`should process ${param} correctly`, async () => { - const res = getParameterType(param as any, options); + const res = getParameterType(param, options); expect(res).to.be.equal(expected); }); @@ -51,7 +58,7 @@ describe('getParameterType', () => { schema: { type: 'array', items: { - $ref: '#/definitions/Item', + $ref: '#/components/schemas/Item', }, }, }; @@ -67,7 +74,7 @@ describe('getParameterType', () => { in: 'body', required: false, schema: { - $ref: '#/definitions/SomeItem', + $ref: '#/components/schemas/SomeItem', }, }; const options = {}; @@ -154,7 +161,7 @@ describe('getParameterType', () => { schema: { type: 'array', items: { - $ref: '#/definitions/Item', + $ref: '#/components/schemas/Item', }, }, }; @@ -166,3 +173,133 @@ describe('getParameterType', () => { }); }); }); + +describe('getTypeFromSchema', () => { + const opts = getClientOptions(); + + describe('arrays', () => { + type TestCase = { + schema: OA3.SchemaObject; + expected: string; + }; + const testCases: TestCase[] = [ + { schema: { type: 'array', items: {} }, expected: 'unknown[]' }, + { schema: { type: 'array', items: null }, expected: 'unknown[]' }, + { + schema: { type: 'array', items: { $ref: '#/components/schemas/Item' } }, + expected: 'Item[]', + }, + { schema: { type: 'array', items: { type: 'string' } }, expected: 'string[]' }, + { schema: { type: 'array', items: { type: 'number' } }, expected: 'number[]' }, + { schema: { type: 'array', items: { type: 'boolean' } }, expected: 'boolean[]' }, + { schema: { type: 'array', items: { type: 'object' } }, expected: 'unknown[]' }, + ]; + + for (const { schema, expected } of testCases) { + it(`should process ${schema} correctly`, async () => { + const res = getTypeFromSchema(schema, opts); + + expect(res).to.be.equal(expected); + }); + } + + it('should process array of objects correctly', () => { + const schema: OA3.SchemaObject = { + type: 'array', + items: { + type: 'object', + required: ['id'], + properties: { + name: { type: 'string', description: 'Name of the item' }, + id: { type: 'number' }, + }, + }, + }; + const res = getTypeFromSchema(schema, opts); + + expect(res).to.equalWI(`{ + name?: string; + id: number; + }[]`); + }); + }); + + describe('objects', () => { + it('should process deep objects correctly', () => { + const schema: OA3.SchemaObject = { + type: 'object', + required: ['id'], + properties: { + name: { type: 'string', description: 'Name of the item' }, + id: { type: 'number' }, + evenDeeper: { + type: 'object', + properties: { + foo: { type: 'string' }, + }, + }, + }, + }; + const res = getTypeFromSchema(schema, opts); + + expect(res).to.equalWI(`{ + name?: string; + id: number; + + evenDeeper?: { + foo?: string; + }; + }`); + }); + }); + + describe('enums', () => { + it('should process string enums correctly', () => { + const schema: OA3.SchemaObject = { + type: 'string', + enum: ['Admin', 'User', 'Guest'], + }; + const res = getTypeFromSchema(schema, opts); + + expect(res).to.equalWI(`("Admin" | "User" | "Guest")`); + }); + + it('should process numeric enums correctly', () => { + const schema: OA3.SchemaObject = { + type: 'number', + enum: [1, 2, 3], + }; + const res = getTypeFromSchema(schema, opts); + + expect(res).to.equalWI('(1 | 2 | 3)'); + }); + }); + + describe('basic types', () => { + type TestCase = { + schema: OA3.SchemaObject; + expected: string; + }; + + const testCases: TestCase[] = [ + { schema: { type: 'string' }, expected: 'string' }, + { schema: { type: 'string', format: 'date-time' }, expected: 'Date' }, + { schema: { type: 'string', format: 'date' }, expected: 'Date' }, + { schema: { type: 'string', format: 'binary' }, expected: 'File' }, + { schema: { type: 'number' }, expected: 'number' }, + { schema: { type: 'integer' }, expected: 'number' }, + { schema: { type: 'boolean' }, expected: 'boolean' }, + { schema: null, expected: 'unknown' }, + { schema: undefined, expected: 'unknown' }, + { schema: {}, expected: 'unknown' }, + ]; + + for (const { schema, expected } of testCases) { + it(`should process ${schema} correctly`, async () => { + const res = getTypeFromSchema(schema, opts); + + expect(res).to.be.equal(expected); + }); + } + }); +}); diff --git a/src/gen/js/support.ts b/src/gen/js/support.ts index d763776..4878e36 100644 --- a/src/gen/js/support.ts +++ b/src/gen/js/support.ts @@ -46,14 +46,9 @@ export function getTypeFromSchema( return `${unknownType}[]`; } if (schema.type === 'object') { - if (schema.additionalProperties) { - const extraProps = schema.additionalProperties; - return `{ [key: string]: ${ - extraProps === true ? 'any' : getTypeFromSchema(extraProps, options) - } }`; - } - return unknownType; + return getTypeFromObject(schema, options); } + if ('enum' in schema) { return `(${schema.enum.map((v) => JSON.stringify(v)).join(' | ')})`; } @@ -71,3 +66,32 @@ export function getTypeFromSchema( } return unknownType; } + +function getTypeFromObject(schema: OA3.SchemaObject, options: Partial): string { + const unknownType = options.preferAny ? 'any' : 'unknown'; + + if (schema.additionalProperties) { + const extraProps = schema.additionalProperties; + return `{ [key: string]: ${ + extraProps === true ? 'any' : getTypeFromSchema(extraProps, options) + } }`; + } + + if (schema.properties) { + const props = Object.keys(schema.properties); + const required = schema.required || []; + const result: string[] = []; + + for (const prop of props) { + const propDefinition = schema.properties[prop]; + const isRequired = required.includes(prop); + result.push( + `${prop}${isRequired ? '' : '?'}: ${getTypeFromSchema(propDefinition, options)};` + ); + } + + return `{ ${result.join('\n')} }`; + } + + return unknownType; +} diff --git a/src/index.spec.ts b/src/index.spec.ts index 661031c..9b0e2ad 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -13,7 +13,7 @@ describe('runCodeGenerator', () => { const parameters = {}; try { - return await runCodeGenerator(parameters as any); + return await runCodeGenerator(parameters); } catch (e) { return expect(e.message).to.contain('You need to provide'); } @@ -25,7 +25,7 @@ describe('runCodeGenerator', () => { }; try { - return await runCodeGenerator(parameters as any); + return await runCodeGenerator(parameters); } catch (e) { return expect(e.message).to.contain('You need to provide'); } @@ -38,7 +38,7 @@ describe('runCodeGenerator', () => { }; try { - return await runCodeGenerator(parameters as any); + return await runCodeGenerator(parameters); } catch (e) { return expect(e.message).to.contain('You need to provide'); } @@ -50,45 +50,47 @@ describe('runCodeGenerator', () => { }; try { - await runCodeGenerator(parameters as any); + await runCodeGenerator(parameters); } catch (e) { return expect(e.message).to.contain('You need to provide'); } }); - it('works with --out and --src provided', () => { + it('works with --out and --src provided', async () => { const parameters = { - src: 'http://petstore.swagger.io/v2/swagger.json', + src: './test/petstore-v3.yml', out: './.tmp/test/', }; - runCodeGenerator(parameters as any).then((res) => { - expect(res).to.be.ok; - }); + const res = await runCodeGenerator(parameters); + expect(res).to.be.ok; }); - it('fails when wrong --config provided', (done) => { + it('fails when wrong --config provided', async () => { const parameters = { config: './test/nonexistent-config.json', }; - runCodeGenerator(parameters as any) - .then(() => {}) - .catch((e) => expect(e).to.contain('Could not correctly load config file')) - .finally(() => done()); + try { + await runCodeGenerator(parameters); + } catch (e) { + return expect(e).to.contain('Could not correctly load config file'); + } }); - it('fails when --config provided and the JSON file is wrong', () => { + it('fails when --config provided and the JSON file is wrong', async () => { const parameters = { config: './test/petstore-v3.yml', }; - return runCodeGenerator(parameters as any).catch((e) => - expect(e).to.contain('Could not correctly load config file') - ); + try { + await runCodeGenerator(parameters); + } catch (e) { + return expect(e).to.contain('Could not correctly load config file'); + } }); - it('works with proper --config provided', (done) => { + it('works with proper --config provided', async () => { const stub = sinon.stub(fetch, 'default'); const response = fs.readFileSync(`${__dirname}/../test/petstore-v3.json`, { encoding: 'utf-8', @@ -99,18 +101,22 @@ describe('runCodeGenerator', () => { config: './test/sample-config.json', }; - runCodeGenerator(parameters as any) - .then((res) => { - expect(res).to.be.ok; - }) - .finally(() => done()); + try { + const res = await runCodeGenerator(parameters); + expect(res).to.be.ok; + } catch (e) { + console.log(e); + return expect(e).to.contain('Could not correctly load config file'); + } }); it('properly loads configuration from config file', async () => { const parameters = { config: './test/sample-config.json', }; + const conf = await applyConfigFile(parameters); + expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://google.pl'); expect(conf.src).to.be.equal( @@ -124,7 +130,9 @@ describe('runCodeGenerator', () => { baseUrl: 'https://wp.pl', src: './test/petstore-v3.yml', }; + const conf = await applyConfigFile(parameters); + expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://wp.pl'); expect(conf.src).to.be.equal('./test/petstore-v3.yml'); diff --git a/src/index.ts b/src/index.ts index f4d2dd7..f20077a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; import genJsCode from './gen/js'; import { loadAllTemplateFiles } from './gen/templateManager'; -import { getOperations } from './swagger'; import type { ClientOptions, FullAppOptions } from './types'; import { loadSpecDocument, verifyDocumentSpec } from './utils'; @@ -11,7 +10,7 @@ import { loadSpecDocument, verifyDocumentSpec } from './utils'; * Runs the whole code generation process. * @returns `CodeGenResult` **/ -export async function runCodeGenerator(options: FullAppOptions): Promise { +export async function runCodeGenerator(options: Partial): Promise { try { verifyOptions(options); const opts = await applyConfigFile(options); @@ -25,7 +24,7 @@ export async function runCodeGenerator(options: FullAppOptions): Promise) { if (!options) { throw new Error('Options were not provided'); } diff --git a/src/schema.ts b/src/schema.ts deleted file mode 100644 index 3c03a54..0000000 --- a/src/schema.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import { escapeReservedWords } from './utils'; -import { camel } from 'case'; - -const models: Model[] = []; - -export default function (spec: OA3.Document) { - function getAllModels() { - return spec.components.schemas; - } - - function handleGenericTypes(): Model[] { - return []; - } -} - -export function getParamName(name: string): string { - return escapeReservedWords( - name - .split('.') - .map((x) => camel(x)) - .join('_') - ); -} - -export function getOperationName(opId: string, group?: string) { - if (!opId) { - return ''; - } - if (!group) { - return opId; - } - - return camel(opId.replace(`${group}_`, '')); -} - -interface Model { - name: string; - identifier: string; - params: (OA3.SchemaObject | OA3.ReferenceObject)[]; -} diff --git a/src/swagger/operations.spec.ts b/src/swagger/operations.spec.ts index 42b6912..e92f54f 100644 --- a/src/swagger/operations.spec.ts +++ b/src/swagger/operations.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { getOperations } from './operations'; import { getDocument } from '../utils'; import type { ApiOperation } from '../types'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; describe('getOperations', () => { it('should handle empty operation list', () => { @@ -103,64 +104,100 @@ describe('getOperations', () => { expect(res).to.deep.equal(validResp); }); - // TODO: Test path inheritance - // it('should handle inheritance of parameters', () => { - // const spec = getDocument({ - // paths: { - // '/api/heartbeat': {}, - // '/api/pokemon': { - // get: { - // tags: ['Pokemon'], - // operationId: null, - // responses: { - // '200': { $ref: '#/components/responses/PokemonList' }, - // }, - // }, - // post: { - // tags: [], - // operationId: null, - // responses: {}, - // }, - // patch: { - // operationId: 'pokePatch', - // responses: {}, - // }, - // }, - // }, - // }); + it('should handle inheritance of parameters', () => { + const inheritedParams: OA3.ParameterObject[] = [ + { + name: 'limit', + in: 'query', + schema: { type: 'integer' }, + }, + { + name: 'offset', + in: 'query', + schema: { type: 'integer' }, + }, + { + name: 'filter', + in: 'query', + schema: { $ref: '#/components/schemas/Filter' }, + }, + ]; + + const spec = getDocument({ + paths: { + '/api/pokemon': { + get: { + operationId: 'A', + parameters: [], + responses: {}, + }, + post: { + operationId: 'B', + responses: {}, + }, + patch: { + operationId: 'C', + parameters: [ + { + name: 'limit', + in: 'query', + schema: { type: 'number', format: 'int32' }, + }, + { + name: 'sort', + in: 'query', + schema: { $ref: '#/components/schemas/Sort' }, + }, + ], + responses: {}, + }, + // parameters that should be inherited by all operations above + parameters: inheritedParams, + }, + }, + }); - // const res = getOperations(spec); + const res = getOperations(spec); - // const validResp: ApiOperation[] = [ - // { - // group: 'Pokemon', - // // id will be generated as sanitized method + path when it's not defined - // operationId: 'getApiPokemon', - // method: 'get', - // parameters: [], - // path: '/api/pokemon', - // responses: { 200: { $ref: '#/components/responses/PokemonList' } }, - // tags: ['Pokemon'], - // }, - // { - // group: 'default', - // // id will be generated as sanitized method + path when it's not defined - // operationId: 'postApiPokemon', - // method: 'post', - // parameters: [], - // path: '/api/pokemon', - // responses: {}, - // tags: [], - // }, - // { - // group: 'default', - // operationId: 'pokePatch', - // method: 'patch', - // parameters: [], - // path: '/api/pokemon', - // responses: {}, - // }, - // ]; - // expect(res).to.deep.equal(validResp); - // }); + const validResp: ApiOperation[] = [ + { + group: 'default', + operationId: 'A', + method: 'get', + parameters: inheritedParams, + path: '/api/pokemon', + responses: {}, + }, + { + group: 'default', + operationId: 'B', + method: 'post', + parameters: inheritedParams, + path: '/api/pokemon', + responses: {}, + }, + { + group: 'default', + operationId: 'C', + method: 'patch', + parameters: [ + { + name: 'limit', + in: 'query', + schema: { type: 'number', format: 'int32' }, + }, + { + name: 'sort', + in: 'query', + schema: { $ref: '#/components/schemas/Sort' }, + }, + inheritedParams[1], + inheritedParams[2], + ], + path: '/api/pokemon', + responses: {}, + }, + ]; + expect(res).to.deep.equal(validResp); + }); }); diff --git a/src/swagger/operations.ts b/src/swagger/operations.ts index a482cd2..053b2e0 100644 --- a/src/swagger/operations.ts +++ b/src/swagger/operations.ts @@ -83,6 +83,7 @@ function getPathOperation( ); // if there's no explicit operationId given, create one based on the method and path + // and make it normalized for further usage if (!op.operationId) { op.operationId = (method + pathInfo.path) .replace(/[\/{(?\/{)\-]([^{.])/g, (_, m) => m.toUpperCase()) diff --git a/src/utils/documentLoader.ts b/src/utils/documentLoader.ts index dc1dd4d..3bae9c0 100644 --- a/src/utils/documentLoader.ts +++ b/src/utils/documentLoader.ts @@ -36,97 +36,3 @@ function readLocalFile(filePath: string) { function parseFileContents(contents: string, path: string): object { return /.ya?ml$/i.test(path) ? YAML.load(contents) : JSON.parse(contents); } - -// function formatSpec(spec: OA3.Document, src?: string, options?: SpecOptions): OA3.Document { -// if (!spec.basePath) { -// spec.basePath = ''; -// } else if (spec.basePath.endsWith('/')) { -// spec.basePath = spec.basePath.slice(0, -1); -// } - -// if (src && /^https?:\/\//im.test(src)) { -// const parts = src.split('/'); -// if (!spec.host) { -// spec.host = parts[2]; -// } -// if (!spec.schemes || !spec.schemes.length) { -// spec.schemes = [parts[0].slice(0, -1)]; -// } -// } else { -// if (!spec.host) { -// spec.host = 'localhost'; -// } -// if (!spec.schemes || !spec.schemes.length) { -// spec.schemes = ['http']; -// } -// } - -// const s: any = spec; -// if (!s.produces || !s.produces.length) { -// s.accepts = ['application/json']; // give sensible default -// } else { -// s.accepts = s.produces; -// } - -// if (!s.consumes) { -// s.contentTypes = []; -// } else { -// s.contentTypes = s.consumes; -// } - -// delete s.consumes; -// delete s.produces; - -// return expandRefs(spec, spec, options) as ApiSpec; -// } - -// /** -// * Recursively expand internal references in the form `#/path/to/object`. -// * -// * @param {object} data the object to search for and update refs -// * @param {object} lookup the object to clone refs from -// * @param {regexp=} refMatch an optional regex to match specific refs to resolve -// * @returns {object} the resolved data object -// */ -// export function expandRefs(data: any, lookup: object, options: SpecOptions): any { -// if (!data) { -// return data; -// } - -// if (Array.isArray(data)) { -// return data.map((item) => expandRefs(item, lookup, options)); -// } -// if (typeof data === 'object') { -// if (dataCache.has(data)) { -// return data; -// } -// if (data.$ref && !(options.ignoreRefType && data.$ref.startsWith(options.ignoreRefType))) { -// const resolved = expandRef(data.$ref, lookup); -// delete data.$ref; -// data = Object.assign({}, resolved, data); -// } -// dataCache.add(data); - -// for (const name in data) { -// data[name] = expandRefs(data[name], lookup, options); -// } -// } -// return data; -// } - -// function expandRef(ref: string, lookup: object): any { -// const parts = ref.split('/'); -// if (parts.shift() !== '#' || !parts[0]) { -// throw new Error(`Only support JSON Schema $refs in format '#/path/to/ref'`); -// } -// let value = lookup; -// while (parts.length) { -// value = value[parts.shift()]; -// if (!value) { -// throw new Error(`Invalid schema reference: ${ref}`); -// } -// } -// return value; -// } - -// const dataCache = new Set(); diff --git a/wallaby.js b/wallaby.js index 61601a2..d4d6db6 100644 --- a/wallaby.js +++ b/wallaby.js @@ -4,6 +4,7 @@ module.exports = () => ({ 'src/**/*.ts', 'test/*.json', 'test/*.yml', + 'templates/**/*.ejs', '!src/**/*.spec.ts' ], require: [], From bf6641c9030e668bb289ef4948ccd9b4a56eb5ef Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 3 Jul 2024 20:50:41 +0200 Subject: [PATCH 08/27] feat: properly support return types chore: upgrade dependencies --- package.json | 8 +- src/gen/js/genOperations.spec.ts | 274 ++++++++++++++++++++++++++++++- src/gen/js/genOperations.ts | 38 ++++- src/gen/js/genTypes.ts | 19 +-- src/gen/js/support.ts | 38 +++++ tsconfig.json | 1 + yarn.lock | 235 +++++++++++++------------- 7 files changed, 465 insertions(+), 148 deletions(-) diff --git a/package.json b/package.json index 1d85150..13fff42 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ ], "dependencies": { "case": "^1.6.3", - "commander": "^10.0.0", + "commander": "^12.1.0", "eta": "^3.4.0", "js-yaml": "^4.1.0", "nanocolors": "^0.2.0", @@ -53,14 +53,14 @@ "devDependencies": { "@types/chai": "4.3.16", "@types/js-yaml": "4.0.9", - "@types/mocha": "10.0.6", + "@types/mocha": "10.0.7", "@types/node-fetch": "2.6.11", "@types/sinon": "17.0.3", "chai": "4.4.1", - "mocha": "10.4.0", + "mocha": "10.6.0", "openapi-types": "^12.1.3", "sinon": "18.0.0", "sucrase": "3.35.0", - "typescript": "5.4.5" + "typescript": "5.5.3" } } diff --git a/src/gen/js/genOperations.spec.ts b/src/gen/js/genOperations.spec.ts index be1e42f..86122c2 100644 --- a/src/gen/js/genOperations.spec.ts +++ b/src/gen/js/genOperations.spec.ts @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { prepareOperations, fixDuplicateOperations, getOperationName } from './genOperations'; import type { ApiOperation } from '../../types'; import { getClientOptions } from '../../utils'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; describe('prepareOperations', () => { const opts = getClientOptions(); @@ -51,7 +52,7 @@ describe('prepareOperations', () => { expect(res.name).to.equal('getPetById'); expect(res.method).to.equal('GET'); - expect(res.body).to.be.undefined; + expect(res.body).to.be.null; expect(res.returnType).to.equal('unknown'); expect(res.headers.pop()).to.deep.include({ @@ -74,6 +75,264 @@ describe('prepareOperations', () => { type: 'number', optional: true, }); + + expect(res.parameters.length).to.equal(3); + expect(res.parameters.map((p) => p.name)).to.deep.equal(['orgID', 'orgType', 'petId']); + }); + + it('should handle empty parameters', () => { + const ops: ApiOperation[] = [ + { + operationId: 'getPetById', + method: 'get', + path: '/pet/{petId}', + responses: {}, + group: null, + }, + { + operationId: 'getPetById2', + method: 'get', + path: '/pets/{petId}', + parameters: [], + responses: {}, + group: null, + }, + ]; + + const [op1, op2] = prepareOperations(ops, opts); + + expect(op1.parameters).to.deep.equal([]); + expect(op2.parameters).to.deep.equal([]); + }); + + describe('requestBody', () => { + it('should handle requestBody with ref type', () => { + const ops: ApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + name: 'body', + optional: false, + originalName: 'body', + type: 'Pet', + original: ops[0].requestBody, + }; + + expect(op1.body).to.deep.equal(expectedBodyParam); + expect(op1.parameters).to.deep.equal([expectedBodyParam]); + }); + + type TestCase = { + schema: OA3.SchemaObject; + expectedType: string; + }; + + const testCases: TestCase[] = [ + { schema: { type: 'string' }, expectedType: 'string' }, + { + schema: { + items: { + format: 'int64', + type: 'integer', + }, + nullable: true, + type: 'array', + }, + expectedType: 'number[]', + }, + { + schema: { + oneOf: [{ $ref: '#/components/schemas/User' }], + }, + expectedType: 'User', + }, + { + schema: { + anyOf: [ + { $ref: '#/components/schemas/User' }, + { $ref: '#/components/schemas/Account' }, + ], + }, + expectedType: 'User | Account', + }, + { + schema: { + allOf: [ + { $ref: '#/components/schemas/User' }, + { $ref: '#/components/schemas/Account' }, + ], + }, + expectedType: 'User & Account', + }, + ]; + + for (const { schema, expectedType } of testCases) { + it(`should handle requestBody with ${schema} schema`, () => { + const ops: ApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet', + requestBody: { + content: { + 'application/json': { + schema, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + name: 'body', + optional: true, + originalName: 'body', + type: expectedType, + original: ops[0].requestBody, + }; + + expect(op1.body).to.deep.equal(expectedBodyParam); + expect(op1.parameters).to.deep.equal([expectedBodyParam]); + }); + } + + it('should handle requestBody along with other parameters', () => { + const ops: ExtendedApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet', + parameters: [ + { + name: 'orgId', + in: 'query', + required: true, + schema: { + type: 'number', + }, + }, + ], + requestBody: { + required: true, + 'x-name': 'pet-body', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + name: 'petBody', + optional: false, + originalName: 'pet-body', + type: 'Pet', + original: ops[0].requestBody, + }; + + expect(op1.body).to.deep.equal(expectedBodyParam); + expect(op1.parameters.length).to.equal(2); + expect(op1.parameters[0]).to.deep.equal(expectedBodyParam); + expect(op1.parameters[1].name).to.equal('orgId'); + }); + + it('should support x-position attributes', () => { + const ops: ExtendedApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet/{orgId}', + parameters: [ + { + name: 'countryId', + in: 'header', + required: false, + schema: { + type: 'number', + }, + 'x-position': 4, + }, + { + name: 'wild', + in: 'query', + schema: { + type: 'boolean', + }, + 'x-position': 2, + }, + { + name: 'orgId', + in: 'path', + required: true, + schema: { + type: 'number', + }, + 'x-position': 1, + }, + ], + requestBody: { + required: true, + 'x-name': 'pet', + 'x-position': 3, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + name: 'pet', + optional: false, + originalName: 'pet', + type: 'Pet', + }; + + op1.body.original = undefined; + expect(op1.body).to.deep.contain(expectedBodyParam); + expect(op1.parameters.length).to.equal(4); + expect(op1.parameters.map((p) => p.name)).to.deep.equal([ + 'orgId', + 'wild', + 'pet', + 'countryId', + ]); + }); }); }); }); @@ -224,3 +483,16 @@ describe('getOperationName', () => { }); } }); + +/** + * ApiOperation that allows extending with x-attributes + */ +interface ExtendedApiOperation extends Omit { + parameters: ( + | OA3.ReferenceObject + | (OA3.ParameterObject & { [key: `x-${string}`]: number | string }) + )[]; + requestBody?: + | OA3.ReferenceObject + | (OA3.RequestBodyObject & { [key: `x-${string}`]: number | string }); +} diff --git a/src/gen/js/genOperations.ts b/src/gen/js/genOperations.ts index 5ce8511..366ca1c 100644 --- a/src/gen/js/genOperations.ts +++ b/src/gen/js/genOperations.ts @@ -63,9 +63,19 @@ export function prepareOperations( const responseObject = getBestResponse(op); const returnType = getParameterType(responseObject, options); + const body = getRequestBody(op.requestBody); const queryParams = getParams(op.parameters as OA3.ParameterObject[], options, ['query']); const params = getParams(op.parameters as OA3.ParameterObject[], options); + if (body) { + params.unshift(body); + } + + // If all parameters have 'x-position' defined, sort them by it + if (params.every((p) => p.original['x-position'])) { + params.sort((a, b) => a.original['x-position'] - b.original['x-position']); + } + return { returnType, method: op.method.toUpperCase(), @@ -74,7 +84,7 @@ export function prepareOperations( parameters: params, query: queryParams, pathParams: getParams(op.parameters as OA3.ParameterObject[], options, ['path']), - body: op.requestBody as OA3.RequestBodyObject, + body, headers: getParams(op.parameters as OA3.ParameterObject[], options, ['header']), }; }); @@ -159,6 +169,29 @@ export function getParamName(name: string): string { ); } +function getRequestBody( + reqBody: OA3.ReferenceObject | OA3.RequestBodyObject +): IOperationParam | null { + if (reqBody && 'content' in reqBody) { + const bodyContent = + reqBody.content['application/json'] ?? + reqBody.content['text/json'] ?? + reqBody.content['text/plain'] ?? + null; + + if (bodyContent) { + return { + originalName: reqBody['x-name'] ?? 'body', + name: getParamName(reqBody['x-name'] ?? 'body'), + type: getParameterType(bodyContent, {}), + optional: !reqBody.required, + original: reqBody, + }; + } + } + return null; +} + interface ClientData { clientName: string; camelCaseName: string; @@ -174,7 +207,7 @@ interface IOperation { parameters: IOperationParam[]; query: IOperationParam[]; pathParams: IOperationParam[]; - body: OA3.RequestBodyObject; + body: IOperationParam; headers: IOperationParam[]; } @@ -183,4 +216,5 @@ interface IOperationParam { name: string; type: string; optional: boolean; + original: OA3.ParameterObject | OA3.RequestBodyObject; } diff --git a/src/gen/js/genTypes.ts b/src/gen/js/genTypes.ts index 4956229..a9bc299 100644 --- a/src/gen/js/genTypes.ts +++ b/src/gen/js/genTypes.ts @@ -1,6 +1,6 @@ import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; -import { getTypeFromSchema } from './support'; +import { getCompositeTypes, getTypeFromSchema } from './support'; import type { ClientOptions } from '../../types'; /** @@ -185,23 +185,6 @@ export function renderComment(comment: string | null) { return ` /**\n${commentLines.map((line) => ` * ${line.trim()}`).join('\n')}\n */`; } -/** - * Returns a string with the types that the given schema extends. - * It uses the `allOf`, `oneOf` or `anyOf` properties to determine the types. - * If the schema has no composite types, it returns an empty string. - * If there are more than one composite types, it analyzes only the first one. - */ -function getCompositeTypes(schema: OA3.SchemaObject) { - const composite = schema.allOf || schema.oneOf || schema.anyOf || []; - if (composite) { - return composite - .filter((v) => '$ref' in v) - .map((s: OA3.ReferenceObject) => s.$ref.split('/').pop()); - } - - return []; -} - function getMergedCompositeObjects(schema: OA3.SchemaObject) { const composite = schema.allOf || schema.oneOf || schema.anyOf || []; const subSchemas = composite.filter((v) => !('$ref' in v)); diff --git a/src/gen/js/support.ts b/src/gen/js/support.ts index 4878e36..5f55481 100644 --- a/src/gen/js/support.ts +++ b/src/gen/js/support.ts @@ -39,6 +39,10 @@ export function getTypeFromSchema( return schema.$ref.split('/').pop(); } + if ('allOf' in schema || 'oneOf' in schema || 'anyOf' in schema) { + return getTypeFromComposites(schema, options); + } + if (schema.type === 'array') { if (schema.items) { return `${getTypeFromSchema(schema.items, options)}[]`; @@ -95,3 +99,37 @@ function getTypeFromObject(schema: OA3.SchemaObject, options: Partial): string { + const unknownType = options.preferAny ? 'any' : 'unknown'; + + const types = getCompositeTypes(schema); + + if (types.length === 0) { + return unknownType; + } + + return schema.allOf ? types.join(' & ') : types.join(' | '); +} + +/** + * Returns a string with the types that the given schema extends. + * It uses the `allOf`, `oneOf` or `anyOf` properties to determine the types. + * If the schema has no composite types, it returns an empty string. + * If there are more than one composite types, then `allOf` is preferred + * over `oneOf` and `anyOf`. Only first type is considered. + */ +export function getCompositeTypes(schema: OA3.SchemaObject) { + const composite = schema.allOf || schema.oneOf || schema.anyOf || []; + if (composite) { + return composite + .filter((v) => '$ref' in v) + .map((s: OA3.ReferenceObject) => s.$ref.split('/').pop()); + } + + return []; +} diff --git a/tsconfig.json b/tsconfig.json index 3ce171b..c59997a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "esModuleInterop": true, "sourceMap": false, "newLine": "lf", + "noEmit": true, "skipLibCheck": true }, "exclude": ["node_modules", "dist", "coverage", "test/snapshots"] diff --git a/yarn.lock b/yarn.lock index 7077750..f3098f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -96,10 +96,10 @@ resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== -"@types/mocha@10.0.6": - version "10.0.6" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.6.tgz#818551d39113081048bdddbef96701b4e8bb9d1b" - integrity sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg== +"@types/mocha@10.0.7": + version "10.0.7" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.7.tgz#4c620090f28ca7f905a94b706f74dc5b57b44f2f" + integrity sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw== "@types/node-fetch@2.6.11": version "2.6.11" @@ -110,9 +110,9 @@ form-data "^4.0.0" "@types/node@*": - version "20.13.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.13.0.tgz#011a76bc1e71ae9a026dddcfd7039084f752c4b6" - integrity sha512-FM6AOb3khNkNIXPnHFDYaHerSv8uN22C91z098AnGccVu+Pcdhi+pNUFDi0iLmPIsVE0JBD0KVS7mzUYt4nRzQ== + version "20.14.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.9.tgz#12e8e765ab27f8c421a1820c99f5f313a933b420" + integrity sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg== dependencies: undici-types "~5.26.4" @@ -128,10 +128,10 @@ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== -ansi-colors@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== +ansi-colors@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== ansi-regex@^5.0.1: version "5.0.1" @@ -207,7 +207,7 @@ braces@~3.0.2: dependencies: fill-range "^7.1.1" -browser-stdout@1.3.1: +browser-stdout@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== @@ -250,10 +250,10 @@ check-error@^1.0.3: dependencies: get-func-name "^2.0.2" -chokidar@3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== dependencies: anymatch "~3.1.2" braces "~3.0.2" @@ -293,10 +293,10 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" - integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== commander@^4.0.0: version "4.1.1" @@ -312,10 +312,10 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -debug@4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== +debug@^4.3.5: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== dependencies: ms "2.1.2" @@ -325,9 +325,9 @@ decamelize@^4.0.0: integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== deep-eql@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" - integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== + version "4.1.4" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7" + integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== dependencies: type-detect "^4.0.0" @@ -336,11 +336,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -diff@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" - integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== - diff@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" @@ -366,7 +361,7 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== -escape-string-regexp@4.0.0: +escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== @@ -383,7 +378,7 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -find-up@5.0.0: +find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== @@ -397,9 +392,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== foreground-child@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" - integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + version "3.2.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.2.1.tgz#767004ccf3a5b30df39bed90718bab43fe0a59f7" + integrity sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA== dependencies: cross-spawn "^7.0.0" signal-exit "^4.0.1" @@ -440,7 +435,19 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@8.1.0: +glob@^10.3.10: + version "10.4.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.2.tgz#bed6b95dade5c1f80b4434daced233aee76160e5" + integrity sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== @@ -451,23 +458,12 @@ glob@8.1.0: minimatch "^5.0.1" once "^1.3.0" -glob@^10.3.10: - version "10.4.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.1.tgz#0cfb01ab6a6b438177bfe6a58e2576f6efe909c2" - integrity sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw== - dependencies: - foreground-child "^3.1.0" - jackspeak "^3.1.2" - minimatch "^9.0.4" - minipass "^7.1.2" - path-scurry "^1.11.1" - has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -he@1.2.0: +he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -530,15 +526,15 @@ isexe@^2.0.0: integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== jackspeak@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.1.2.tgz#eada67ea949c6b71de50f1b09c92a961897b90ab" - integrity sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ== + version "3.4.0" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.0.tgz#a75763ff36ad778ede6a156d8ee8b124de445b4a" + integrity sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw== dependencies: "@isaacs/cliui" "^8.0.2" optionalDependencies: "@pkgjs/parseargs" "^0.11.0" -js-yaml@4.1.0, js-yaml@^4.1.0: +js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== @@ -567,7 +563,7 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== -log-symbols@4.1.0: +log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== @@ -583,9 +579,9 @@ loupe@^2.3.6: get-func-name "^2.0.1" lru-cache@^10.2.0: - version "10.2.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.2.tgz#48206bc114c1252940c41b25b41af5b545aca878" - integrity sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ== + version "10.3.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.3.0.tgz#4a4aaf10c84658ab70f79a85a9a3f1e1fb11196b" + integrity sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ== mime-db@1.52.0: version "1.52.0" @@ -599,14 +595,7 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" -minimatch@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" - integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^5.0.1: +minimatch@^5.0.1, minimatch@^5.1.6: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== @@ -614,9 +603,9 @@ minimatch@^5.0.1: brace-expansion "^2.0.1" minimatch@^9.0.4: - version "9.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" - integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: brace-expansion "^2.0.1" @@ -625,38 +614,38 @@ minimatch@^9.0.4: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -mocha@10.4.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.4.0.tgz#ed03db96ee9cfc6d20c56f8e2af07b961dbae261" - integrity sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA== - dependencies: - ansi-colors "4.1.1" - browser-stdout "1.3.1" - chokidar "3.5.3" - debug "4.3.4" - diff "5.0.0" - escape-string-regexp "4.0.0" - find-up "5.0.0" - glob "8.1.0" - he "1.2.0" - js-yaml "4.1.0" - log-symbols "4.1.0" - minimatch "5.0.1" - ms "2.1.3" - serialize-javascript "6.0.0" - strip-json-comments "3.1.1" - supports-color "8.1.1" - workerpool "6.2.1" - yargs "16.2.0" - yargs-parser "20.2.4" - yargs-unparser "2.0.0" +mocha@10.6.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.6.0.tgz#465fc66c52613088e10018989a3b98d5e11954b9" + integrity sha512-hxjt4+EEB0SA0ZDygSS015t65lJw/I2yRCS3Ae+SJ5FrbzrXgfYwJr96f0OvIXdj7h4lv/vLCrH3rkiuizFSvw== + dependencies: + ansi-colors "^4.1.3" + browser-stdout "^1.3.1" + chokidar "^3.5.3" + debug "^4.3.5" + diff "^5.2.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^8.1.0" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^5.1.6" + ms "^2.1.3" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^6.5.1" + yargs "^16.2.0" + yargs-parser "^20.2.9" + yargs-unparser "^2.0.0" ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3: +ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -729,6 +718,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -791,10 +785,10 @@ safe-buffer@^5.1.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -serialize-javascript@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" - integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== dependencies: randombytes "^2.1.0" @@ -875,7 +869,7 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" -strip-json-comments@3.1.1: +strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -893,13 +887,6 @@ sucrase@3.35.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" -supports-color@8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-color@^7, supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -907,6 +894,13 @@ supports-color@^7, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -943,10 +937,10 @@ type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== -typescript@5.4.5: - version "5.4.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" - integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== +typescript@5.5.3: + version "5.5.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.3.tgz#e1b0a3c394190838a0b168e771b0ad56a0af0faa" + integrity sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ== undici-types@~5.26.4: version "5.26.5" @@ -973,10 +967,10 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -workerpool@6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" - integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== +workerpool@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" + integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" @@ -1015,17 +1009,12 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yargs-parser@20.2.4: - version "20.2.4" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== - -yargs-parser@^20.2.2: +yargs-parser@^20.2.2, yargs-parser@^20.2.9: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== -yargs-unparser@2.0.0: +yargs-unparser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== @@ -1035,7 +1024,7 @@ yargs-unparser@2.0.0: flat "^5.0.2" is-plain-obj "^2.1.0" -yargs@16.2.0: +yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== From 6704687cf02d540963a29d9c54a9841b8c38eea9 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 3 Jul 2024 21:30:11 +0200 Subject: [PATCH 09/27] impr: use undici for handling http request chore: remove some unused libraries as a result --- package.json | 6 +- src/index.spec.ts | 27 +++-- src/utils/documentLoader.spec.ts | 34 +++---- src/utils/documentLoader.ts | 12 +-- src/utils/test.utils.ts | 27 +++++ yarn.lock | 165 ++----------------------------- 6 files changed, 76 insertions(+), 195 deletions(-) diff --git a/package.json b/package.json index 13fff42..97bb62a 100644 --- a/package.json +++ b/package.json @@ -48,18 +48,16 @@ "eta": "^3.4.0", "js-yaml": "^4.1.0", "nanocolors": "^0.2.0", - "node-fetch": "^2.6.7" + "undici": "^6.19.2" }, "devDependencies": { "@types/chai": "4.3.16", "@types/js-yaml": "4.0.9", "@types/mocha": "10.0.7", - "@types/node-fetch": "2.6.11", - "@types/sinon": "17.0.3", + "@types/node": "20.14.9", "chai": "4.4.1", "mocha": "10.6.0", "openapi-types": "^12.1.3", - "sinon": "18.0.0", "sucrase": "3.35.0", "typescript": "5.5.3" } diff --git a/src/index.spec.ts b/src/index.spec.ts index 9b0e2ad..5741c3e 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,13 +1,20 @@ import { expect } from 'chai'; -import fs from 'node:fs'; -import * as fetch from 'node-fetch'; -import { Response } from 'node-fetch'; -import sinon from 'sinon'; +import { MockAgent, setGlobalDispatcher } from 'undici'; import { runCodeGenerator, applyConfigFile } from './'; +import { mockRequest } from './utils'; describe('runCodeGenerator', () => { - afterEach(sinon.restore); + let mockAgent: MockAgent; + + beforeEach(() => { + // Create a new MockAgent + mockAgent = new MockAgent(); + // Make sure that we don't actually make real requests + mockAgent.disableNetConnect(); + // Set the mocked agent as the global dispatcher + setGlobalDispatcher(mockAgent); + }); it('fails with no parameters provided', async () => { const parameters = {}; @@ -91,11 +98,11 @@ describe('runCodeGenerator', () => { }); it('works with proper --config provided', async () => { - const stub = sinon.stub(fetch, 'default'); - const response = fs.readFileSync(`${__dirname}/../test/petstore-v3.json`, { - encoding: 'utf-8', - }); - stub.returns(new Promise((resolve) => resolve(new Response(response)))); + mockRequest( + mockAgent, + 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json', + 'petstore-v3.json' + ); const parameters = { config: './test/sample-config.json', diff --git a/src/utils/documentLoader.spec.ts b/src/utils/documentLoader.spec.ts index cf95a2a..8959ecb 100644 --- a/src/utils/documentLoader.spec.ts +++ b/src/utils/documentLoader.spec.ts @@ -1,25 +1,29 @@ import { expect } from 'chai'; -import fs from 'node:fs'; -import * as fetch from 'node-fetch'; -import { Response } from 'node-fetch'; -import sinon from 'sinon'; +import { MockAgent, setGlobalDispatcher } from 'undici'; + import { loadSpecDocument } from './documentLoader'; +import { mockRequest } from './test.utils'; // URLs are not used to fetch anything. We are faking responses through SinonJS const petstore3 = { - json: 'http://petstore.swagger.io/v3/swagger.json', - yaml: 'http://petstore.swagger.io/v3/swagger.yaml', + json: 'https://petstore.swagger.io/v3/swagger.json', + yaml: 'https://petstore.swagger.io/v3/swagger.yaml', }; describe('loadSpecDocument', () => { - afterEach(sinon.restore); + let mockAgent: MockAgent; + + beforeEach(() => { + // Create a new MockAgent + mockAgent = new MockAgent(); + // Make sure that we don't actually make real requests + mockAgent.disableNetConnect(); + // Set the mocked agent as the global dispatcher + setGlobalDispatcher(mockAgent); + }); it('should resolve a JSON spec from url', async () => { - const stub = sinon.stub(fetch, 'default'); - const response = fs.readFileSync(`${__dirname}/../../test/petstore-v3.json`, { - encoding: 'utf-8', - }); - stub.returns(new Promise((resolve) => resolve(new Response(response)))); + mockRequest(mockAgent, petstore3.json, 'petstore-v3.json'); const spec = await loadSpecDocument(petstore3.json); expect(spec).to.be.ok; @@ -27,11 +31,7 @@ describe('loadSpecDocument', () => { }); it('should resolve a YAML spec from url', async () => { - const stub = sinon.stub(fetch, 'default'); - const response = fs.readFileSync(`${__dirname}/../../test/petstore-v3.yml`, { - encoding: 'utf-8', - }); - stub.returns(new Promise((resolve) => resolve(new Response(response)))); + mockRequest(mockAgent, petstore3.yaml, 'petstore-v3.yml'); const spec = await loadSpecDocument(petstore3.yaml); expect(spec).to.be.ok; diff --git a/src/utils/documentLoader.ts b/src/utils/documentLoader.ts index 3bae9c0..a9aede5 100644 --- a/src/utils/documentLoader.ts +++ b/src/utils/documentLoader.ts @@ -1,7 +1,7 @@ -import YAML from 'js-yaml'; import fs from 'node:fs'; -import fetch from 'node-fetch'; +import YAML from 'js-yaml'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; +import { request } from 'undici'; export async function loadSpecDocument(src: string | object): Promise { if (typeof src === 'string') { @@ -21,10 +21,10 @@ function loadFile(src: string): Promise { throw new Error(`Unable to load api at '${src}'`); } -function loadFromUrl(url: string) { - return fetch(url) - .then((resp) => resp.text()) - .then((contents) => parseFileContents(contents, url)); +async function loadFromUrl(url: string) { + const { body } = await request(url); + const contents = await body.text(); + return parseFileContents(contents, url); } function readLocalFile(filePath: string) { diff --git a/src/utils/test.utils.ts b/src/utils/test.utils.ts index b22cdcb..9526761 100644 --- a/src/utils/test.utils.ts +++ b/src/utils/test.utils.ts @@ -1,4 +1,8 @@ +import fs from 'node:fs'; +import path from 'node:path'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; +import type { MockAgent } from 'undici'; + import type { ClientOptions } from '../types'; /** @@ -31,3 +35,26 @@ export function getClientOptions(opts: Partial = {}): ClientOptio ...opts, }; } + +/** + * Utility that will set up a mock response for a given URL + * @param mockAgent Agent that will be used to intercept the request + * @param url Full URL to intercept + * @param responseFileName Filename that contains the response. It will be loaded from the test folder + */ +export function mockRequest(mockAgent: MockAgent, url: string, responseFileName: string) { + const urlObject = new URL(url); + const mockPool = mockAgent.get(urlObject.origin); + + const response = fs.readFileSync(path.join(__dirname, '..', '..', 'test', responseFileName), { + encoding: 'utf-8', + }); + + // Set up the mock response + mockPool + .intercept({ + path: urlObject.pathname, + method: 'GET', + }) + .reply(200, response); +} diff --git a/yarn.lock b/yarn.lock index f3098f6..b57dc19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,41 +51,6 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@sinonjs/commons@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" - integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== - dependencies: - type-detect "4.0.8" - -"@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" - integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== - dependencies: - type-detect "4.0.8" - -"@sinonjs/fake-timers@^11.2.2": - version "11.2.2" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" - integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== - dependencies: - "@sinonjs/commons" "^3.0.0" - -"@sinonjs/samsam@^8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" - integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== - dependencies: - "@sinonjs/commons" "^2.0.0" - lodash.get "^4.4.2" - type-detect "^4.0.8" - -"@sinonjs/text-encoding@^0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" - integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== - "@types/chai@4.3.16": version "4.3.16" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.16.tgz#b1572967f0b8b60bf3f87fe1d854a5604ea70c82" @@ -101,33 +66,13 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.7.tgz#4c620090f28ca7f905a94b706f74dc5b57b44f2f" integrity sha512-GN8yJ1mNTcFcah/wKEFIJckJx9iJLoMSzWcfRRuxz/Jk+U6KQNnml+etbtxFK8lPjzOw3zp4Ha/kjSst9fsHYw== -"@types/node-fetch@2.6.11": - version "2.6.11" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" - integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== - dependencies: - "@types/node" "*" - form-data "^4.0.0" - -"@types/node@*": +"@types/node@20.14.9": version "20.14.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.9.tgz#12e8e765ab27f8c421a1820c99f5f313a933b420" integrity sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg== dependencies: undici-types "~5.26.4" -"@types/sinon@17.0.3": - version "17.0.3" - resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa" - integrity sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw== - dependencies: - "@types/sinonjs__fake-timers" "*" - -"@types/sinonjs__fake-timers@*": - version "8.1.5" - resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" - integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== - ansi-colors@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -178,11 +123,6 @@ assertion-error@^1.1.0: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -286,13 +226,6 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -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@^12.1.0: version "12.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" @@ -331,11 +264,6 @@ deep-eql@^4.1.3: dependencies: type-detect "^4.0.0" -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - diff@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" @@ -399,15 +327,6 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" -form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - mime-types "^2.1.12" - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -541,11 +460,6 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" -just-extend@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" - integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== - lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -558,11 +472,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash.get@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" - integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== - log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -583,18 +492,6 @@ lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.3.0.tgz#4a4aaf10c84658ab70f79a85a9a3f1e1fb11196b" integrity sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ== -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - minimatch@^5.0.1, minimatch@^5.1.6: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -664,24 +561,6 @@ nanocolors@^0.2.0: resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b" integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA== -nise@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/nise/-/nise-6.0.0.tgz#ae56fccb5d912037363c3b3f29ebbfa28bde8b48" - integrity sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg== - dependencies: - "@sinonjs/commons" "^3.0.0" - "@sinonjs/fake-timers" "^11.2.2" - "@sinonjs/text-encoding" "^0.7.2" - just-extend "^6.2.0" - path-to-regexp "^6.2.1" - -node-fetch@^2.6.7: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -741,11 +620,6 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@^6.2.1: - version "6.2.2" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.2.tgz#324377a83e5049cbecadc5554d6a63a9a4866b36" - integrity sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw== - pathval@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" @@ -809,18 +683,6 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -sinon@18.0.0: - version "18.0.0" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-18.0.0.tgz#69ca293dbc3e82590a8b0d46c97f63ebc1e5fc01" - integrity sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA== - dependencies: - "@sinonjs/commons" "^3.0.1" - "@sinonjs/fake-timers" "^11.2.2" - "@sinonjs/samsam" "^8.0.0" - diff "^5.2.0" - nise "^6.0.0" - supports-color "^7" - "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -887,7 +749,7 @@ sucrase@3.35.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" -supports-color@^7, supports-color@^7.1.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -922,17 +784,12 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - ts-interface-checker@^0.1.9: version "0.1.13" resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.8: +type-detect@^4.0.0, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== @@ -947,18 +804,10 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" +undici@^6.19.2: + version "6.19.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.19.2.tgz#231bc5de78d0dafb6260cf454b294576c2f3cd31" + integrity sha512-JfjKqIauur3Q6biAtHJ564e3bWa8VvT+7cSiOJHFbX4Erv6CLGDpg8z+Fmg/1OI/47RA+GI2QZaF48SSaLvyBA== which@^2.0.1: version "2.0.2" From 9303b3f9d9823fcae074c248be947a918852ba60 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 3 Jul 2024 21:45:21 +0200 Subject: [PATCH 10/27] tests: re-enable snapshot tests + regen chore: use update and more extensive petstore spec samples --- .mocharc.json | 3 +- test/petstore-v3.json | 153 ++++--- test/petstore-v3.yml | 812 +++++++++++++++++++++++++++++++++--- test/snapshots.spec.ts | 2 +- test/snapshots/axios.ts | 158 +++---- test/snapshots/fetch.ts | 151 ++++--- test/snapshots/ng1.ts | 166 ++++---- test/snapshots/ng2.ts | 155 +++---- test/snapshots/swr-axios.ts | 175 ++++---- test/snapshots/xior.ts | 158 +++---- 10 files changed, 1289 insertions(+), 644 deletions(-) diff --git a/.mocharc.json b/.mocharc.json index 57f2300..95e14e3 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -8,6 +8,7 @@ "./test/test-setup.ts" ], "spec": [ - "src/**/*.spec.ts" + "src/**/*.spec.ts", + "test/*.spec.ts" ] } diff --git a/test/petstore-v3.json b/test/petstore-v3.json index 27156a2..56761c7 100644 --- a/test/petstore-v3.json +++ b/test/petstore-v3.json @@ -3,51 +3,61 @@ "info": { "version": "1.0.0", "title": "Swagger Petstore", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "Swagger API Team", + "email": "apiteam@swagger.io", + "url": "http://swagger.io" + }, "license": { - "name": "MIT" + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" } }, "servers": [ { - "url": "http://petstore.swagger.io/v1" + "url": "https://petstore.swagger.io/v2" } ], "paths": { "/pets": { "get": { - "summary": "List all pets", - "operationId": "listPets", - "tags": [ - "pets" - ], + "operationId": "findPets", "parameters": [ + { + "name": "tags", + "in": "query", + "description": "tags to filter by", + "required": false, + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "name": "limit", "in": "query", - "description": "How many items to return at one time (max 100)", + "description": "maximum number of results to return", "required": false, "schema": { "type": "integer", - "maximum": 100, "format": "int32" } } ], "responses": { "200": { - "description": "A paged array of pets", - "headers": { - "x-next": { - "description": "A link to the next page of responses", - "schema": { - "type": "string" - } - } - }, + "description": "pet response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Pets" + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } } } } @@ -65,24 +75,29 @@ } }, "post": { - "summary": "Create a pet", - "operationId": "createPets", - "tags": [ - "pets" - ], + "description": "Creates a new pet in the store. Duplicates are allowed", + "operationId": "addPet", "requestBody": { + "description": "Pet to add to the store", + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Pet" + "$ref": "#/components/schemas/NewPet" } } - }, - "required": true + } }, "responses": { - "201": { - "description": "Null response" + "200": { + "description": "pet response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } }, "default": { "description": "unexpected error", @@ -97,27 +112,25 @@ } } }, - "/pets/{petId}": { + "/pets/{id}": { "get": { - "summary": "Info for a specific pet", - "operationId": "showPetById", - "tags": [ - "pets" - ], + "description": "Returns a user based on a single ID, if the user does not have access to the pet", + "operationId": "find pet by id", "parameters": [ { - "name": "petId", + "name": "id", "in": "path", + "description": "ID of pet to fetch", "required": true, - "description": "The id of the pet to retrieve", "schema": { - "type": "string" + "type": "integer", + "format": "int64" } } ], "responses": { "200": { - "description": "Expected response to a valid request", + "description": "pet response", "content": { "application/json": { "schema": { @@ -137,22 +150,67 @@ } } } + }, + "delete": { + "description": "deletes a single pet based on the ID supplied", + "operationId": "deletePet", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of pet to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "pet deleted" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } } } }, "components": { "schemas": { "Pet": { + "allOf": [ + { + "$ref": "#/components/schemas/NewPet" + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + } + } + ] + }, + "NewPet": { "type": "object", "required": [ - "id", "name" ], "properties": { - "id": { - "type": "integer", - "format": "int64" - }, "name": { "type": "string" }, @@ -161,13 +219,6 @@ } } }, - "Pets": { - "type": "array", - "maxItems": 100, - "items": { - "$ref": "#/components/schemas/Pet" - } - }, "Error": { "type": "object", "required": [ diff --git a/test/petstore-v3.yml b/test/petstore-v3.yml index b01683d..f5271a5 100644 --- a/test/petstore-v3.yml +++ b/test/petstore-v3.yml @@ -1,119 +1,819 @@ -openapi: '3.0.0' +openapi: 3.0.2 +servers: + - url: /v3 info: - version: 1.0.0 - title: Swagger Petstore + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + version: 1.0.20-SNAPSHOT + title: Swagger Petstore - OpenAPI 3.0 + termsOfService: 'http://swagger.io/terms/' + contact: + email: apiteam@swagger.io license: - name: MIT -servers: - - url: http://petstore.swagger.io/v1 + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: 'http://swagger.io' + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: 'http://swagger.io' + - name: user + description: Operations about user paths: - /pets: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + description: Create a new pet in the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + description: Update an existent pet in the store + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + /pet/findByTags: get: - summary: List all pets - operationId: listPets tags: - - pets + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags parameters: - - name: limit + - name: tags in: query - description: How many items to return at one time (max 100) + description: Tags to filter by required: false + explode: true + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true schema: type: integer - maximum: 100 - format: int32 + format: int64 responses: '200': - description: A paged array of pets - headers: - x-next: - description: A link to the next page of responses + description: successful operation + content: + application/xml: schema: - type: string + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - 'write:pets' + - 'read:pets' + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + description: '' + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + responses: + '200': + description: successful operation content: application/json: schema: - $ref: '#/components/schemas/Pets' - default: - description: unexpected error + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + x-swagger-router-controller: OrderController + responses: + '200': + description: successful operation content: application/json: schema: - $ref: '#/components/schemas/Error' + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: post: - summary: Create a pet - operationId: createPets tags: - - pets + - store + summary: Place an order for a pet + description: Place a new order in the store + operationId: placeOrder + x-swagger-router-controller: OrderController + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '405': + description: Invalid input requestBody: content: application/json: schema: - $ref: '#/components/schemas/Pet' - required: true + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Order' + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + x-swagger-router-controller: OrderController + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 responses: - '201': - description: Null response - default: - description: unexpected error + '200': + description: successful operation content: + application/xml: + schema: + $ref: '#/components/schemas/Order' application/json: schema: - $ref: '#/components/schemas/Error' - /pets/{petId}: - get: - summary: Info for a specific pet - operationId: showPetById + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: tags: - - pets + - store + summary: Delete purchase order by ID + x-swagger-router-controller: OrderController + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder parameters: - - name: petId + - name: orderId in: path + description: ID of the order that needs to be deleted required: true - description: The id of the pet to retrieve + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + description: Created user object + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: 'Creates list of users with given input array' + x-swagger-router-controller: UserController + operationId: createUsersWithListInput + responses: + '200': + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + default: + description: successful operation + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false schema: type: string responses: '200': - description: Expected response to a valid request + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time content: + application/xml: + schema: + type: string application/json: schema: - $ref: '#/components/schemas/Pet' + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + parameters: [] + responses: default: - description: unexpected error + description: successful operation + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + '200': + description: successful operation content: + application/xml: + schema: + $ref: '#/components/schemas/User' application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Update user + x-swagger-router-controller: UserController + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that needs to be updated + required: true + schema: + type: string + responses: + default: + description: successful operation + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' components: schemas: - Pet: + Order: + x-swagger-router-model: io.swagger.petstore.model.Order + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + example: approved + complete: + type: boolean + xml: + name: order + type: object + Customer: + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + items: + $ref: '#/components/schemas/Address' + xml: + wrapped: true + name: addresses + xml: + name: customer + type: object + Address: + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: 94301 + xml: + name: address + type: object + Category: + x-swagger-router-model: io.swagger.petstore.model.Category + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category type: object + User: + x-swagger-router-model: io.swagger.petstore.model.User + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: 12345 + phone: + type: string + example: 12345 + userStatus: + type: integer + format: int32 + example: 1 + description: User Status + xml: + name: user + type: object + Tag: + x-swagger-router-model: io.swagger.petstore.model.Tag + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + type: object + Pet: + x-swagger-router-model: io.swagger.petstore.model.Pet required: - - id - name + - photoUrls properties: id: type: integer format: int64 + example: 10 name: type: string - tag: + example: doggie + category: + $ref: '#/components/schemas/Category' + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: '#/components/schemas/Tag' + xml: + name: tag + status: type: string - Pets: - type: array - maxItems: 100 - items: - $ref: '#/components/schemas/Pet' - Error: + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet type: object - required: - - code - - message + ApiResponse: properties: code: type: integer format: int32 + type: + type: string message: type: string + xml: + name: '##default' + type: object + requestBodies: + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'https://petstore.swagger.io/oauth/authorize' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/test/snapshots.spec.ts b/test/snapshots.spec.ts index e2d79d3..0d71daa 100644 --- a/test/snapshots.spec.ts +++ b/test/snapshots.spec.ts @@ -11,7 +11,7 @@ describe('petstore snapshots', () => { it(`should match existing ${template} snapshot`, async () => { const snapshotFile = `./test/snapshots/${template}.ts`; const parameters: FullAppOptions = { - src: './test/petstore-v3.json', + src: './test/petstore-v3.yml', out: './.tmp/test/', template, }; diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index 041567e..8330c13 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -21,10 +21,10 @@ export const petClient = { */ addPet(body: Pet , $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/pet'; - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, @@ -54,9 +54,9 @@ export const petClient = { }, /** - * @param status + * @param status (optional) */ - findPetsByStatus(status: ('available'|'pending'|'sold')[] , + findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/findByStatus'; @@ -72,9 +72,9 @@ export const petClient = { }, /** - * @param tags + * @param tags (optional) */ - findPetsByTags(tags: string[] , + findPetsByTags(tags: string[] | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/findByTags'; @@ -110,10 +110,10 @@ export const petClient = { */ updatePet(body: Pet , $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/pet'; - return axios.request({ + return axios.request({ url: url, method: 'PUT', data: body, @@ -133,18 +133,14 @@ export const petClient = { ): AxiosPromise { let url = '/pet/{petId}'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!name) { - formDataBody.append("name", name); - } - if (!!status) { - formDataBody.append("status", status); - } return axios.request({ url: url, method: 'POST', - data: formDataBody, + params: { + 'name': serializeQueryParam(name), + 'status': serializeQueryParam(status), + }, ...$config, }); }, @@ -152,27 +148,20 @@ export const petClient = { /** * @param petId * @param additionalMetadata (optional) - * @param file (optional) */ uploadFile(petId: number , additionalMetadata: string | null | undefined, - file: File | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/{petId}/uploadImage'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!additionalMetadata) { - formDataBody.append("additionalMetadata", additionalMetadata); - } - if (!!file) { - formDataBody.append("file", file); - } return axios.request({ url: url, method: 'POST', - data: formDataBody, + params: { + 'additionalMetadata': serializeQueryParam(additionalMetadata), + }, ...$config, }); }, @@ -226,9 +215,9 @@ export const storeClient = { }, /** - * @param body + * @param body (optional) */ - placeOrder(body: Order , + placeOrder(body: Order | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/store/order'; @@ -245,30 +234,14 @@ export const storeClient = { export const userClient = { /** - * @param body + * @param body (optional) */ - createUser(body: User , + createUser(body: User | null | undefined, $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/user'; - return axios.request({ - url: url, - method: 'POST', - data: body, - ...$config, - }); - }, - - /** - * @param body - */ - createUsersWithArrayInput(body: User[] , - $config?: AxiosRequestConfig - ): AxiosPromise { - let url = '/user/createWithArray'; - - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, @@ -277,14 +250,14 @@ export const userClient = { }, /** - * @param body + * @param body (optional) */ - createUsersWithListInput(body: User[] , + createUsersWithListInput(body: User[] | null | undefined, $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/user/createWithList'; - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, @@ -325,11 +298,11 @@ export const userClient = { }, /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) */ - loginUser(username: string , - password: string , + loginUser(username: string | null | undefined, + password: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/login'; @@ -359,11 +332,11 @@ export const userClient = { }, /** + * @param body (optional) * @param username - * @param body */ - updateUser(username: string , - body: User , + updateUser(body: User | null | undefined, + username: string , $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/{username}'; @@ -389,41 +362,29 @@ function serializeQueryParam(obj: any) { .join('&'); } -export interface ApiResponse { - code?: number; - type?: string; - message?: string; -} - -export interface Category { - id?: number; - name?: string; -} - -export interface Pet { - name: string; - photoUrls: string[]; - id?: number; - category?: Category; - tags?: Tag[]; - - status?: 'available'|'pending'|'sold'; -} - -export interface Tag { - id?: number; - name?: string; -} - export interface Order { id?: number; petId?: number; quantity?: number; shipDate?: Date; +// Order Status + status?: ("placed" | "approved" | "delivered"); + complete?: boolean;} - status?: 'placed'|'approved'|'delivered'; - complete?: boolean; -} +export interface Customer { + id?: number; + username?: string; + address?: Address[];} + +export interface Address { + street?: string; + city?: string; + state?: string; + zip?: string;} + +export interface Category { + id?: number; + name?: string;} export interface User { id?: number; @@ -433,6 +394,23 @@ export interface User { email?: string; password?: string; phone?: string; +// User Status + userStatus?: number;} - userStatus?: number; -} +export interface Tag { + id?: number; + name?: string;} + +export interface Pet { + id?: number; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; +// pet status in the store + status?: ("available" | "pending" | "sold");} + +export interface ApiResponse { + code?: number; + type?: string; + message?: string;} diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index c285865..53d1cda 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -19,14 +19,14 @@ export const petClient = { */ addPet(body: Pet , $config?: RequestInit - ): Promise { + ): Promise { let url = defaults.baseUrl + '/pet?'; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }).then((response) => response.json() as Promise); }, /** @@ -50,14 +50,14 @@ export const petClient = { }, /** - * @param status + * @param status (optional) */ - findPetsByStatus(status: ('available'|'pending'|'sold')[] , + findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: RequestInit ): Promise { let url = defaults.baseUrl + '/pet/findByStatus?'; if (status !== undefined) { - status.forEach(item => { url += 'status=' + serializeQueryParam(item) + "&"; }); + url += 'status=' + serializeQueryParam(status) + "&"; } return fetch(url, { @@ -67,14 +67,14 @@ export const petClient = { }, /** - * @param tags + * @param tags (optional) */ - findPetsByTags(tags: string[] , + findPetsByTags(tags: string[] | null | undefined, $config?: RequestInit ): Promise { let url = defaults.baseUrl + '/pet/findByTags?'; if (tags !== undefined) { - tags.forEach(item => { url += 'tags=' + serializeQueryParam(item) + "&"; }); + url += 'tags=' + serializeQueryParam(tags) + "&"; } return fetch(url, { @@ -103,14 +103,14 @@ export const petClient = { */ updatePet(body: Pet , $config?: RequestInit - ): Promise { + ): Promise { let url = defaults.baseUrl + '/pet?'; return fetch(url, { method: 'PUT', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }).then((response) => response.json() as Promise); }, /** @@ -125,7 +125,13 @@ export const petClient = { ): Promise { let url = defaults.baseUrl + '/pet/{petId}?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - + if (name !== undefined) { + url += 'name=' + serializeQueryParam(name) + "&"; + } + if (status !== undefined) { + url += 'status=' + serializeQueryParam(status) + "&"; + } + return fetch(url, { method: 'POST', ...$config, @@ -135,16 +141,17 @@ export const petClient = { /** * @param petId * @param additionalMetadata (optional) - * @param file (optional) */ uploadFile(petId: number , additionalMetadata: string | null | undefined, - file: File | null | undefined, $config?: RequestInit ): Promise { let url = defaults.baseUrl + '/pet/{petId}/uploadImage?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - + if (additionalMetadata !== undefined) { + url += 'additionalMetadata=' + serializeQueryParam(additionalMetadata) + "&"; + } + return fetch(url, { method: 'POST', ...$config, @@ -196,9 +203,9 @@ export const storeClient = { }, /** - * @param body + * @param body (optional) */ - placeOrder(body: Order , + placeOrder(body: Order | null | undefined, $config?: RequestInit ): Promise { let url = defaults.baseUrl + '/store/order?'; @@ -213,48 +220,33 @@ export const storeClient = { }; export const userClient = { /** - * @param body + * @param body (optional) */ - createUser(body: User , + createUser(body: User | null | undefined, $config?: RequestInit - ): Promise { + ): Promise { let url = defaults.baseUrl + '/user?'; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); - }, - - /** - * @param body - */ - createUsersWithArrayInput(body: User[] , - $config?: RequestInit - ): Promise { - let url = defaults.baseUrl + '/user/createWithArray?'; - - return fetch(url, { - method: 'POST', - body: JSON.stringify(body), - ...$config, - }).then((response) => response.json() as Promise); + }).then((response) => response.json() as Promise); }, /** - * @param body + * @param body (optional) */ - createUsersWithListInput(body: User[] , + createUsersWithListInput(body: User[] | null | undefined, $config?: RequestInit - ): Promise { + ): Promise { let url = defaults.baseUrl + '/user/createWithList?'; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }).then((response) => response.json() as Promise); }, /** @@ -288,11 +280,11 @@ export const userClient = { }, /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) */ - loginUser(username: string , - password: string , + loginUser(username: string | null | undefined, + password: string | null | undefined, $config?: RequestInit ): Promise { let url = defaults.baseUrl + '/user/login?'; @@ -322,11 +314,11 @@ export const userClient = { }, /** + * @param body (optional) * @param username - * @param body */ - updateUser(username: string , - body: User , + updateUser(body: User | null | undefined, + username: string , $config?: RequestInit ): Promise { let url = defaults.baseUrl + '/user/{username}?'; @@ -350,41 +342,29 @@ function serializeQueryParam(obj: any) { .join('&'); } -export interface ApiResponse { - code?: number; - type?: string; - message?: string; -} - -export interface Category { - id?: number; - name?: string; -} - -export interface Pet { - name: string; - photoUrls: string[]; - id?: number; - category?: Category; - tags?: Tag[]; - - status?: 'available'|'pending'|'sold'; -} - -export interface Tag { - id?: number; - name?: string; -} - export interface Order { id?: number; petId?: number; quantity?: number; shipDate?: Date; +// Order Status + status?: ("placed" | "approved" | "delivered"); + complete?: boolean;} - status?: 'placed'|'approved'|'delivered'; - complete?: boolean; -} +export interface Customer { + id?: number; + username?: string; + address?: Address[];} + +export interface Address { + street?: string; + city?: string; + state?: string; + zip?: string;} + +export interface Category { + id?: number; + name?: string;} export interface User { id?: number; @@ -394,6 +374,23 @@ export interface User { email?: string; password?: string; phone?: string; +// User Status + userStatus?: number;} - userStatus?: number; -} +export interface Tag { + id?: number; + name?: string;} + +export interface Pet { + id?: number; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; +// pet status in the store + status?: ("available" | "pending" | "sold");} + +export interface ApiResponse { + code?: number; + type?: string; + message?: string;} diff --git a/test/snapshots/ng1.ts b/test/snapshots/ng1.ts index a872340..29744d0 100644 --- a/test/snapshots/ng1.ts +++ b/test/snapshots/ng1.ts @@ -134,7 +134,7 @@ export class petService extends BaseService { */ addPet(body: Pet , config?: IRequestShortcutConfig - ): IPromise { + ): IPromise { let url = '/pet?'; return this.$post( @@ -163,15 +163,15 @@ export class petService extends BaseService { } /** - * @param status + * @param status (optional) * @return Success */ - findPetsByStatus(status: ('available'|'pending'|'sold')[] , + findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, config?: IRequestShortcutConfig ): IPromise { let url = '/pet/findByStatus?'; if (status !== undefined) { - status.forEach(item => { url += serializeQueryParam(item, 'status') + "&"; }); + url += serializeQueryParam(status, 'status') + "&"; } return this.$get( @@ -181,15 +181,15 @@ export class petService extends BaseService { } /** - * @param tags + * @param tags (optional) * @return Success */ - findPetsByTags(tags: string[] , + findPetsByTags(tags: string[] | null | undefined, config?: IRequestShortcutConfig ): IPromise { let url = '/pet/findByTags?'; if (tags !== undefined) { - tags.forEach(item => { url += serializeQueryParam(item, 'tags') + "&"; }); + url += serializeQueryParam(tags, 'tags') + "&"; } return this.$get( @@ -220,7 +220,7 @@ export class petService extends BaseService { */ updatePet(body: Pet , config?: IRequestShortcutConfig - ): IPromise { + ): IPromise { let url = '/pet?'; return this.$put( @@ -239,21 +239,20 @@ export class petService extends BaseService { updatePetWithForm(petId: number , name: string | null | undefined, status: string | null | undefined, - config: IRequestShortcutConfig = {headers: {'Content-Type': undefined}} + config?: IRequestShortcutConfig ): IPromise { let url = '/pet/{petId}?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!name) { - formDataBody.append("name", name); - } - if (!!status) { - formDataBody.append("status", status); - } - + if (name !== undefined) { + url += serializeQueryParam(name, 'name') + "&"; + } + if (status !== undefined) { + url += serializeQueryParam(status, 'status') + "&"; + } + return this.$post( url, - formDataBody, + null, config ); } @@ -261,27 +260,21 @@ export class petService extends BaseService { /** * @param petId * @param additionalMetadata (optional) - * @param file (optional) * @return Success */ uploadFile(petId: number , additionalMetadata: string | null | undefined, - file: File | null | undefined, - config: IRequestShortcutConfig = {headers: {'Content-Type': undefined}} + config?: IRequestShortcutConfig ): IPromise { let url = '/pet/{petId}/uploadImage?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!additionalMetadata) { - formDataBody.append("additionalMetadata", additionalMetadata); - } - if (!!file) { - formDataBody.append("file", file); - } - + if (additionalMetadata !== undefined) { + url += serializeQueryParam(additionalMetadata, 'additionalMetadata') + "&"; + } + return this.$post( url, - formDataBody, + null, config ); } @@ -340,10 +333,10 @@ export class storeService extends BaseService { } /** - * @param body + * @param body (optional) * @return Success */ - placeOrder(body: Order , + placeOrder(body: Order | null | undefined, config?: IRequestShortcutConfig ): IPromise { let url = '/store/order?'; @@ -364,12 +357,12 @@ export class userService extends BaseService { } /** - * @param body + * @param body (optional) * @return Success */ - createUser(body: User , + createUser(body: User | null | undefined, config?: IRequestShortcutConfig - ): IPromise { + ): IPromise { let url = '/user?'; return this.$post( @@ -380,28 +373,12 @@ export class userService extends BaseService { } /** - * @param body + * @param body (optional) * @return Success */ - createUsersWithArrayInput(body: User[] , + createUsersWithListInput(body: User[] | null | undefined, config?: IRequestShortcutConfig - ): IPromise { - let url = '/user/createWithArray?'; - - return this.$post( - url, - body, - config - ); - } - - /** - * @param body - * @return Success - */ - createUsersWithListInput(body: User[] , - config?: IRequestShortcutConfig - ): IPromise { + ): IPromise { let url = '/user/createWithList?'; return this.$post( @@ -444,12 +421,12 @@ export class userService extends BaseService { } /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) * @return Success */ - loginUser(username: string , - password: string , + loginUser(username: string | null | undefined, + password: string | null | undefined, config?: IRequestShortcutConfig ): IPromise { let url = '/user/login?'; @@ -480,12 +457,12 @@ export class userService extends BaseService { } /** + * @param body (optional) * @param username - * @param body * @return Success */ - updateUser(username: string , - body: User , + updateUser(body: User | null | undefined, + username: string , config?: IRequestShortcutConfig ): IPromise { let url = '/user/{username}?'; @@ -536,41 +513,29 @@ function serializeQueryParam(obj: any, property: string): string { return ''; } } -export interface ApiResponse { - code?: number; - type?: string; - message?: string; -} - -export interface Category { - id?: number; - name?: string; -} - -export interface Pet { - name: string; - photoUrls: string[]; - id?: number; - category?: Category; - tags?: Tag[]; - - status?: 'available'|'pending'|'sold'; -} - -export interface Tag { - id?: number; - name?: string; -} - export interface Order { id?: number; petId?: number; quantity?: number; shipDate?: Date; +// Order Status + status?: ("placed" | "approved" | "delivered"); + complete?: boolean;} - status?: 'placed'|'approved'|'delivered'; - complete?: boolean; -} +export interface Customer { + id?: number; + username?: string; + address?: Address[];} + +export interface Address { + street?: string; + city?: string; + state?: string; + zip?: string;} + +export interface Category { + id?: number; + name?: string;} export interface User { id?: number; @@ -580,6 +545,23 @@ export interface User { email?: string; password?: string; phone?: string; +// User Status + userStatus?: number;} - userStatus?: number; -} +export interface Tag { + id?: number; + name?: string;} + +export interface Pet { + id?: number; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; +// pet status in the store + status?: ("available" | "pending" | "sold");} + +export interface ApiResponse { + code?: number; + type?: string; + message?: string;} diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index 99c7b84..d7b671a 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -82,7 +82,7 @@ export class petService extends BaseService { addPet( body: Pet, config?: any - ): Observable { + ): Observable { let url = '/pet?'; return this.$post( @@ -112,16 +112,16 @@ export class petService extends BaseService { } /** - * @param status + * @param status (optional) * @return Success */ findPetsByStatus( - status: ('available'|'pending'|'sold')[], + status: ("available" | "pending" | "sold") | null | undefined, config?: any ): Observable { let url = '/pet/findByStatus?'; if (status !== undefined) { - status.forEach(item => { url += 'status=' + encodeURIComponent("" + item) + "&"; }); + url += 'status=' + encodeURIComponent("" + status) + "&"; } return this.$get( @@ -131,16 +131,16 @@ export class petService extends BaseService { } /** - * @param tags + * @param tags (optional) * @return Success */ findPetsByTags( - tags: string[], + tags: string[] | null | undefined, config?: any ): Observable { let url = '/pet/findByTags?'; if (tags !== undefined) { - tags.forEach(item => { url += 'tags=' + encodeURIComponent("" + item) + "&"; }); + url += 'tags=' + encodeURIComponent("" + tags) + "&"; } return this.$get( @@ -173,7 +173,7 @@ export class petService extends BaseService { updatePet( body: Pet, config?: any - ): Observable { + ): Observable { let url = '/pet?'; return this.$put( @@ -197,17 +197,16 @@ export class petService extends BaseService { ): Observable { let url = '/pet/{petId}?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!name) { - formDataBody.append("name", name); + if (name !== undefined) { + url += 'name=' + encodeURIComponent("" + name) + "&"; } - if (!!status) { - formDataBody.append("status", status); + if (status !== undefined) { + url += 'status=' + encodeURIComponent("" + status) + "&"; } - + return this.$post( url, - formDataBody, + null, config ); } @@ -215,28 +214,22 @@ export class petService extends BaseService { /** * @param petId * @param additionalMetadata (optional) - * @param file (optional) * @return Success */ uploadFile( petId: number, additionalMetadata: string | null | undefined, - file: File | null | undefined, config?: any ): Observable { let url = '/pet/{petId}/uploadImage?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!additionalMetadata) { - formDataBody.append("additionalMetadata", additionalMetadata); + if (additionalMetadata !== undefined) { + url += 'additionalMetadata=' + encodeURIComponent("" + additionalMetadata) + "&"; } - if (!!file) { - formDataBody.append("file", file); - } - + return this.$post( url, - formDataBody, + null, config ); } @@ -303,11 +296,11 @@ export class storeService extends BaseService { } /** - * @param body + * @param body (optional) * @return Success */ placeOrder( - body: Order, + body: Order | null | undefined, config?: any ): Observable { let url = '/store/order?'; @@ -333,13 +326,13 @@ export class userService extends BaseService { } /** - * @param body + * @param body (optional) * @return Success */ createUser( - body: User, + body: User | null | undefined, config?: any - ): Observable { + ): Observable { let url = '/user?'; return this.$post( @@ -350,30 +343,13 @@ export class userService extends BaseService { } /** - * @param body - * @return Success - */ - createUsersWithArrayInput( - body: User[], - config?: any - ): Observable { - let url = '/user/createWithArray?'; - - return this.$post( - url, - body, - config - ); - } - -/** - * @param body + * @param body (optional) * @return Success */ createUsersWithListInput( - body: User[], + body: User[] | null | undefined, config?: any - ): Observable { + ): Observable { let url = '/user/createWithList?'; return this.$post( @@ -418,13 +394,13 @@ export class userService extends BaseService { } /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) * @return Success */ loginUser( - username: string, - password: string, + username: string | null | undefined, + password: string | null | undefined, config?: any ): Observable { let url = '/user/login?'; @@ -456,13 +432,13 @@ export class userService extends BaseService { } /** + * @param body (optional) * @param username - * @param body * @return Success */ updateUser( + body: User | null | undefined, username: string, - body: User, config?: any ): Observable { let url = '/user/{username}?'; @@ -477,41 +453,29 @@ export class userService extends BaseService { } -export interface ApiResponse { - code?: number; - type?: string; - message?: string; -} - -export interface Category { - id?: number; - name?: string; -} - -export interface Pet { - name: string; - photoUrls: string[]; - id?: number; - category?: Category; - tags?: Tag[]; - - status?: 'available'|'pending'|'sold'; -} - -export interface Tag { - id?: number; - name?: string; -} - export interface Order { id?: number; petId?: number; quantity?: number; shipDate?: Date; +// Order Status + status?: ("placed" | "approved" | "delivered"); + complete?: boolean;} - status?: 'placed'|'approved'|'delivered'; - complete?: boolean; -} +export interface Customer { + id?: number; + username?: string; + address?: Address[];} + +export interface Address { + street?: string; + city?: string; + state?: string; + zip?: string;} + +export interface Category { + id?: number; + name?: string;} export interface User { id?: number; @@ -521,6 +485,23 @@ export interface User { email?: string; password?: string; phone?: string; +// User Status + userStatus?: number;} - userStatus?: number; -} +export interface Tag { + id?: number; + name?: string;} + +export interface Pet { + id?: number; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; +// pet status in the store + status?: ("available" | "pending" | "sold");} + +export interface ApiResponse { + code?: number; + type?: string; + message?: string;} diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 695e20e..5934d59 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -29,11 +29,11 @@ export const petClient = { */ addPet( body: Pet , $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/pet'; - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, @@ -64,9 +64,9 @@ export const petClient = { }, /** - * @param status + * @param status (optional) */ - findPetsByStatus( status: ('available'|'pending'|'sold')[] , + findPetsByStatus( status: ("available" | "pending" | "sold") | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/findByStatus'; @@ -83,9 +83,9 @@ export const petClient = { }, /** - * @param tags + * @param tags (optional) */ - findPetsByTags( tags: string[] , + findPetsByTags( tags: string[] | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/findByTags'; @@ -123,11 +123,11 @@ export const petClient = { */ updatePet( body: Pet , $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/pet'; - return axios.request({ + return axios.request({ url: url, method: 'PUT', data: body, @@ -148,18 +148,14 @@ export const petClient = { let url = '/pet/{petId}'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!name) { - formDataBody.append("name", name); - } - if (!!status) { - formDataBody.append("status", status); - } return axios.request({ url: url, method: 'POST', - data: formDataBody, + params: { + 'name': serializeQueryParam(name), + 'status': serializeQueryParam(status), + }, ...$config, }); }, @@ -167,28 +163,21 @@ export const petClient = { /** * @param petId * @param additionalMetadata (optional) - * @param file (optional) */ uploadFile( petId: number , additionalMetadata: string | null | undefined, - file: File | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/{petId}/uploadImage'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!additionalMetadata) { - formDataBody.append("additionalMetadata", additionalMetadata); - } - if (!!file) { - formDataBody.append("file", file); - } return axios.request({ url: url, method: 'POST', - data: formDataBody, + params: { + 'additionalMetadata': serializeQueryParam(additionalMetadata), + }, ...$config, }); }, @@ -196,9 +185,9 @@ export const petClient = { }; /** - * @param status + * @param status (optional) */ -export function usepetfindPetsByStatus( status: ('available'|'pending'|'sold')[] , +export function usepetfindPetsByStatus( status: ("available" | "pending" | "sold") | null | undefined, $config?: SwrConfig ) { let url = '/pet/findByStatus'; @@ -231,9 +220,9 @@ export function usepetfindPetsByStatus( status: ('available'|'pending'|'sold')[ } /** - * @param tags + * @param tags (optional) */ -export function usepetfindPetsByTags( tags: string[] , +export function usepetfindPetsByTags( tags: string[] | null | undefined, $config?: SwrConfig ) { let url = '/pet/findByTags'; @@ -344,9 +333,9 @@ const { data, error, mutate } = useSWR( }, /** - * @param body + * @param body (optional) */ - placeOrder( body: Order , + placeOrder( body: Order | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/store/order'; @@ -419,32 +408,15 @@ const { data, error, mutate } = useSWR( export const userClient = { /** - * @param body + * @param body (optional) */ - createUser( body: User , + createUser( body: User | null | undefined, $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/user'; - return axios.request({ - url: url, - method: 'POST', - data: body, - ...$config, - }); - }, - - /** - * @param body - */ - createUsersWithArrayInput( body: User[] , - $config?: AxiosRequestConfig - ): AxiosPromise { - let url = '/user/createWithArray'; - - - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, @@ -453,15 +425,15 @@ const { data, error, mutate } = useSWR( }, /** - * @param body + * @param body (optional) */ - createUsersWithListInput( body: User[] , + createUsersWithListInput( body: User[] | null | undefined, $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/user/createWithList'; - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, @@ -504,11 +476,11 @@ const { data, error, mutate } = useSWR( }, /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) */ - loginUser( username: string , - password: string , + loginUser( username: string | null | undefined, + password: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/login'; @@ -540,11 +512,11 @@ const { data, error, mutate } = useSWR( }, /** + * @param body (optional) * @param username - * @param body */ - updateUser( username: string , - body: User , + updateUser( body: User | null | undefined, + username: string , $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/{username}'; @@ -591,11 +563,11 @@ const { data, error, mutate } = useSWR( } /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) */ -export function useuserloginUser( username: string , - password: string , +export function useuserloginUser( username: string | null | undefined, + password: string | null | undefined, $config?: SwrConfig ) { let url = '/user/login'; @@ -667,41 +639,29 @@ function serializeQueryParam(obj: any) { .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) .join('&'); } -export interface ApiResponse { - code?: number; - type?: string; - message?: string; -} - -export interface Category { - id?: number; - name?: string; -} - -export interface Pet { - name: string; - photoUrls: string[]; - id?: number; - category?: Category; - tags?: Tag[]; - - status?: 'available'|'pending'|'sold'; -} - -export interface Tag { - id?: number; - name?: string; -} - export interface Order { id?: number; petId?: number; quantity?: number; shipDate?: Date; +// Order Status + status?: ("placed" | "approved" | "delivered"); + complete?: boolean;} - status?: 'placed'|'approved'|'delivered'; - complete?: boolean; -} +export interface Customer { + id?: number; + username?: string; + address?: Address[];} + +export interface Address { + street?: string; + city?: string; + state?: string; + zip?: string;} + +export interface Category { + id?: number; + name?: string;} export interface User { id?: number; @@ -711,6 +671,23 @@ export interface User { email?: string; password?: string; phone?: string; +// User Status + userStatus?: number;} - userStatus?: number; -} +export interface Tag { + id?: number; + name?: string;} + +export interface Pet { + id?: number; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; +// pet status in the store + status?: ("available" | "pending" | "sold");} + +export interface ApiResponse { + code?: number; + type?: string; + message?: string;} diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 605dab6..9877aca 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -21,10 +21,10 @@ export const petClient = { */ addPet(body: Pet , $config?: XiorRequestConfig - ): Promise> { + ): Promise> { let url = '/pet'; - return http.request({ + return http.request({ url: url, method: 'POST', data: body, @@ -54,9 +54,9 @@ export const petClient = { }, /** - * @param status + * @param status (optional) */ - findPetsByStatus(status: ('available'|'pending'|'sold')[] , + findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: XiorRequestConfig ): Promise> { let url = '/pet/findByStatus'; @@ -72,9 +72,9 @@ export const petClient = { }, /** - * @param tags + * @param tags (optional) */ - findPetsByTags(tags: string[] , + findPetsByTags(tags: string[] | null | undefined, $config?: XiorRequestConfig ): Promise> { let url = '/pet/findByTags'; @@ -110,10 +110,10 @@ export const petClient = { */ updatePet(body: Pet , $config?: XiorRequestConfig - ): Promise> { + ): Promise> { let url = '/pet'; - return http.request({ + return http.request({ url: url, method: 'PUT', data: body, @@ -133,18 +133,14 @@ export const petClient = { ): Promise> { let url = '/pet/{petId}'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!name) { - formDataBody.append("name", name); - } - if (!!status) { - formDataBody.append("status", status); - } return http.request({ url: url, method: 'POST', - data: formDataBody, + params: { + 'name': serializeQueryParam(name), + 'status': serializeQueryParam(status), + }, ...$config, }); }, @@ -152,27 +148,20 @@ export const petClient = { /** * @param petId * @param additionalMetadata (optional) - * @param file (optional) */ uploadFile(petId: number , additionalMetadata: string | null | undefined, - file: File | null | undefined, $config?: XiorRequestConfig ): Promise> { let url = '/pet/{petId}/uploadImage'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - const formDataBody = new FormData(); - if (!!additionalMetadata) { - formDataBody.append("additionalMetadata", additionalMetadata); - } - if (!!file) { - formDataBody.append("file", file); - } return http.request({ url: url, method: 'POST', - data: formDataBody, + params: { + 'additionalMetadata': serializeQueryParam(additionalMetadata), + }, ...$config, }); }, @@ -226,9 +215,9 @@ export const storeClient = { }, /** - * @param body + * @param body (optional) */ - placeOrder(body: Order , + placeOrder(body: Order | null | undefined, $config?: XiorRequestConfig ): Promise> { let url = '/store/order'; @@ -245,30 +234,14 @@ export const storeClient = { export const userClient = { /** - * @param body + * @param body (optional) */ - createUser(body: User , + createUser(body: User | null | undefined, $config?: XiorRequestConfig - ): Promise> { + ): Promise> { let url = '/user'; - return http.request({ - url: url, - method: 'POST', - data: body, - ...$config, - }); - }, - - /** - * @param body - */ - createUsersWithArrayInput(body: User[] , - $config?: XiorRequestConfig - ): Promise> { - let url = '/user/createWithArray'; - - return http.request({ + return http.request({ url: url, method: 'POST', data: body, @@ -277,14 +250,14 @@ export const userClient = { }, /** - * @param body + * @param body (optional) */ - createUsersWithListInput(body: User[] , + createUsersWithListInput(body: User[] | null | undefined, $config?: XiorRequestConfig - ): Promise> { + ): Promise> { let url = '/user/createWithList'; - return http.request({ + return http.request({ url: url, method: 'POST', data: body, @@ -325,11 +298,11 @@ export const userClient = { }, /** - * @param username - * @param password + * @param username (optional) + * @param password (optional) */ - loginUser(username: string , - password: string , + loginUser(username: string | null | undefined, + password: string | null | undefined, $config?: XiorRequestConfig ): Promise> { let url = '/user/login'; @@ -359,11 +332,11 @@ export const userClient = { }, /** + * @param body (optional) * @param username - * @param body */ - updateUser(username: string , - body: User , + updateUser(body: User | null | undefined, + username: string , $config?: XiorRequestConfig ): Promise> { let url = '/user/{username}'; @@ -389,41 +362,29 @@ function serializeQueryParam(obj: any) { .join('&'); } -export interface ApiResponse { - code?: number; - type?: string; - message?: string; -} - -export interface Category { - id?: number; - name?: string; -} - -export interface Pet { - name: string; - photoUrls: string[]; - id?: number; - category?: Category; - tags?: Tag[]; - - status?: 'available'|'pending'|'sold'; -} - -export interface Tag { - id?: number; - name?: string; -} - export interface Order { id?: number; petId?: number; quantity?: number; shipDate?: Date; +// Order Status + status?: ("placed" | "approved" | "delivered"); + complete?: boolean;} - status?: 'placed'|'approved'|'delivered'; - complete?: boolean; -} +export interface Customer { + id?: number; + username?: string; + address?: Address[];} + +export interface Address { + street?: string; + city?: string; + state?: string; + zip?: string;} + +export interface Category { + id?: number; + name?: string;} export interface User { id?: number; @@ -433,6 +394,23 @@ export interface User { email?: string; password?: string; phone?: string; +// User Status + userStatus?: number;} - userStatus?: number; -} +export interface Tag { + id?: number; + name?: string;} + +export interface Pet { + id?: number; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; +// pet status in the store + status?: ("available" | "pending" | "sold");} + +export interface ApiResponse { + code?: number; + type?: string; + message?: string;} From 697400b9196bb8e7f6ce18e642fcad905069919e Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Thu, 4 Jul 2024 12:34:02 +0200 Subject: [PATCH 11/27] chore: reorganize code chore: remove not used functions and types --- .github/workflows/node.yml | 2 +- src/gen/{js => }/createBarrel.ts | 4 +- src/gen/{js => }/genOperations.spec.ts | 4 +- src/gen/{js => }/genOperations.ts | 14 +- src/gen/{js => }/genTypes.spec.ts | 2 +- src/gen/{js => }/genTypes.ts | 2 +- src/gen/{js => }/index.ts | 4 +- src/gen/{js => }/support.spec.ts | 4 +- src/gen/{js => }/support.ts | 2 +- src/gen/util.spec.ts | 278 --------------- src/gen/util.ts | 100 ------ src/index.ts | 5 +- src/types.ts | 52 --- src/utils/index.ts | 1 + .../serializeQueryParam.angular1.spec.ts | 0 .../js => utils}/serializeQueryParam.spec.ts | 0 src/{gen => utils}/templateManager.spec.ts | 0 src/{gen => utils}/templateManager.ts | 3 + src/utils/utils.spec.ts | 322 ++++++++++++++++-- src/utils/utils.ts | 87 +++++ 20 files changed, 412 insertions(+), 474 deletions(-) rename src/gen/{js => }/createBarrel.ts (85%) rename src/gen/{js => }/genOperations.spec.ts (99%) rename src/gen/{js => }/genOperations.ts (92%) rename src/gen/{js => }/genTypes.spec.ts (99%) rename src/gen/{js => }/genTypes.ts (99%) rename src/gen/{js => }/index.ts (81%) rename src/gen/{js => }/support.spec.ts (98%) rename src/gen/{js => }/support.ts (98%) delete mode 100644 src/gen/util.spec.ts delete mode 100644 src/gen/util.ts rename src/{gen/js => utils}/serializeQueryParam.angular1.spec.ts (100%) rename src/{gen/js => utils}/serializeQueryParam.spec.ts (100%) rename src/{gen => utils}/templateManager.spec.ts (100%) rename src/{gen => utils}/templateManager.ts (91%) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index c40e028..cd69ad8 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [18.x, 20.x, 21.x, 22.x] steps: - uses: actions/checkout@v4 diff --git a/src/gen/js/createBarrel.ts b/src/gen/createBarrel.ts similarity index 85% rename from src/gen/js/createBarrel.ts rename to src/gen/createBarrel.ts index fe57d88..e6134dd 100644 --- a/src/gen/js/createBarrel.ts +++ b/src/gen/createBarrel.ts @@ -1,6 +1,6 @@ import { camel } from 'case'; -import type { ApiOperation, ClientOptions } from '../../types'; -import { renderFile } from '../templateManager'; +import type { ApiOperation, ClientOptions } from '../types'; +import { renderFile } from '../utils'; type ClientGroups = { [key: string]: ApiOperation[]; diff --git a/src/gen/js/genOperations.spec.ts b/src/gen/genOperations.spec.ts similarity index 99% rename from src/gen/js/genOperations.spec.ts rename to src/gen/genOperations.spec.ts index 86122c2..2e220f2 100644 --- a/src/gen/js/genOperations.spec.ts +++ b/src/gen/genOperations.spec.ts @@ -1,8 +1,8 @@ import { expect } from 'chai'; import { prepareOperations, fixDuplicateOperations, getOperationName } from './genOperations'; -import type { ApiOperation } from '../../types'; -import { getClientOptions } from '../../utils'; +import type { ApiOperation } from '../types'; +import { getClientOptions } from '../utils'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; describe('prepareOperations', () => { diff --git a/src/gen/js/genOperations.ts b/src/gen/genOperations.ts similarity index 92% rename from src/gen/js/genOperations.ts rename to src/gen/genOperations.ts index 366ca1c..78ceca8 100644 --- a/src/gen/js/genOperations.ts +++ b/src/gen/genOperations.ts @@ -2,12 +2,11 @@ import { camel } from 'case'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { getParameterType } from './support'; -import { groupOperationsByGroupName, getBestResponse, orderBy } from '../util'; +import { groupOperationsByGroupName, getBestResponse, orderBy, renderFile } from '../utils'; import { generateBarrelFile } from './createBarrel'; -import { renderFile } from '../templateManager'; -import type { ApiOperation, ClientOptions } from '../../types'; -import { escapeReservedWords } from '../../utils'; -import { getOperations } from '../../swagger'; +import type { ApiOperation, ClientOptions } from '../types'; +import { escapeReservedWords } from '../utils'; +import { getOperations } from '../swagger'; export default async function genOperations( spec: OA3.Document, @@ -117,6 +116,11 @@ export function fixDuplicateOperations(operations: ApiOperation[]): ApiOperation return results; } +/** + * Some spec generators include group name in the operationId. We need to remove them as they are redundant. + * @example + * getOperationName('Group_Operation', 'Group') -> 'Operation' + * */ export function getOperationName(opId: string | null, group?: string | null) { if (!opId) { return ''; diff --git a/src/gen/js/genTypes.spec.ts b/src/gen/genTypes.spec.ts similarity index 99% rename from src/gen/js/genTypes.spec.ts rename to src/gen/genTypes.spec.ts index 26be6c1..27e56a7 100644 --- a/src/gen/js/genTypes.spec.ts +++ b/src/gen/genTypes.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; import genTypes, { renderComment } from './genTypes'; -import { getClientOptions, getDocument } from '../../utils'; +import { getClientOptions, getDocument } from '../utils'; describe('genTypes', () => { const opts = getClientOptions(); diff --git a/src/gen/js/genTypes.ts b/src/gen/genTypes.ts similarity index 99% rename from src/gen/js/genTypes.ts rename to src/gen/genTypes.ts index a9bc299..8a6f699 100644 --- a/src/gen/js/genTypes.ts +++ b/src/gen/genTypes.ts @@ -1,7 +1,7 @@ import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; import { getCompositeTypes, getTypeFromSchema } from './support'; -import type { ClientOptions } from '../../types'; +import type { ClientOptions } from '../types'; /** * Generates TypeScript types for the given OpenAPI 3 document. diff --git a/src/gen/js/index.ts b/src/gen/index.ts similarity index 81% rename from src/gen/js/index.ts rename to src/gen/index.ts index f72e2df..c4574d4 100644 --- a/src/gen/js/index.ts +++ b/src/gen/index.ts @@ -2,8 +2,8 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; import genOperations from './genOperations'; import genTypes from './genTypes'; -import { saveFile, prepareOutputFilename } from '../util'; -import type { ClientOptions } from '../../types'; +import { saveFile, prepareOutputFilename } from '../utils'; +import type { ClientOptions } from '../types'; export default async function genCode(spec: OA3.Document, options: ClientOptions): Promise { let fileContents = await genOperations(spec, options); diff --git a/src/gen/js/support.spec.ts b/src/gen/support.spec.ts similarity index 98% rename from src/gen/js/support.spec.ts rename to src/gen/support.spec.ts index 5d46e3b..5e56974 100644 --- a/src/gen/js/support.spec.ts +++ b/src/gen/support.spec.ts @@ -1,8 +1,8 @@ import { expect } from 'chai'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import type { ClientOptions } from '../../types'; +import type { ClientOptions } from '../types'; import { getParameterType, getTypeFromSchema } from './support'; -import { getClientOptions } from '../../utils'; +import { getClientOptions } from '../utils'; describe('getParameterType', () => { describe('empty cases', () => { diff --git a/src/gen/js/support.ts b/src/gen/support.ts similarity index 98% rename from src/gen/js/support.ts rename to src/gen/support.ts index 5f55481..5d7d930 100644 --- a/src/gen/js/support.ts +++ b/src/gen/support.ts @@ -1,4 +1,4 @@ -import type { ClientOptions } from '../../types'; +import type { ClientOptions } from '../types'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; /** diff --git a/src/gen/util.spec.ts b/src/gen/util.spec.ts deleted file mode 100644 index 41ee11e..0000000 --- a/src/gen/util.spec.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { expect } from 'chai'; -import type { OpenAPIV3 as OA3 } from 'openapi-types'; - -import { groupOperationsByGroupName, getBestResponse, prepareOutputFilename } from './util'; -import type { ApiOperation } from '../types'; - -describe('groupOperationsByGroupName', () => { - const testCases = [ - { input: [], expected: {} }, - { input: null, expected: {} }, - { input: undefined, expected: {} }, - ]; - for (const { input, expected } of testCases) { - it(`should handle ${input} as input`, async () => { - const res = groupOperationsByGroupName(input); - - expect(res).to.deep.equal(expected); - }); - } - - it('handles single operation', async () => { - const def: ApiOperation[] = [ - { - operationId: 'HealthCheck_PerformAllChecks', - method: 'get', - group: 'HealthCheck', - path: '/healthcheck', - parameters: [ - { - in: 'query', - name: 'token', - required: false, - schema: { - type: 'string', - }, - }, - ], - responses: { - '200': { - description: 'Success', - }, - }, - tags: ['HealthCheck'], - }, - ]; - - const res = groupOperationsByGroupName(def); - - expect(res).to.be.ok; - expect(res.HealthCheck).to.be.ok; - expect(res.HealthCheck.length).to.eq(1); - }); - - it('handles two different operations and the same group', async () => { - const def: ApiOperation[] = [ - { - operationId: 'HealthCheck_PerformAllChecks', - method: 'get', - group: 'HealthCheck', - path: '/healthcheck', - parameters: [ - { - in: 'query', - name: 'token', - required: false, - schema: { - type: 'string', - }, - }, - ], - responses: { - '200': { - description: 'Success', - }, - }, - tags: ['HealthCheck'], - }, - { - operationId: 'HealthCheck_SomethingElse', - method: 'post', - group: 'HealthCheck', - path: '/healthcheck', - parameters: [ - { - in: 'query', - name: 'token', - required: false, - schema: { - type: 'string', - }, - }, - ], - responses: { - '200': { - description: 'Success', - }, - }, - tags: ['HealthCheck'], - }, - ]; - - const res = groupOperationsByGroupName(def); - - expect(res).to.be.ok; - expect(res.HealthCheck).to.be.ok; - expect(res.HealthCheck.length).to.eq(2); - }); - - it('handles two different operations and different groups', async () => { - const def: ApiOperation[] = [ - { - operationId: 'HealthCheck_PerformAllChecks', - method: 'get', - group: 'HealthCheck', - path: '/healthcheck', - parameters: [ - { - in: 'query', - name: 'token', - required: false, - schema: { - type: 'string', - }, - }, - ], - responses: { - '200': { - description: 'Success', - }, - }, - tags: ['HealthCheck'], - }, - { - operationId: 'Illness_SomethingElse', - method: 'get', - group: 'Illness', - path: '/illness', - parameters: [ - { - in: 'query', - name: 'token', - required: false, - schema: { - type: 'string', - }, - }, - ], - responses: { - '200': { - description: 'Success', - }, - }, - tags: ['Illness'], - }, - ]; - - const res = groupOperationsByGroupName(def); - - expect(res).to.be.ok; - expect(res.HealthCheck).to.be.ok; - expect(res.HealthCheck.length).to.eq(1); - expect(res.Illness).to.be.ok; - expect(res.Illness.length).to.eq(1); - }); -}); - -describe('prepareOutputFilename', () => { - for (const { given, expected } of [ - { given: null, expected: null }, - { given: 'api.ts', expected: 'api.ts' }, - { given: 'api', expected: 'api.ts' }, - { given: 'api/', expected: 'api/index.ts' }, - { given: 'api\\', expected: 'api/index.ts' }, - { given: 'api/api.ts', expected: 'api/api.ts' }, - { given: 'api//api.ts', expected: 'api//api.ts' }, - { given: 'api\\api.ts', expected: 'api/api.ts' }, - { given: 'api/api/', expected: 'api/api/index.ts' }, - ]) { - it(`handles "${given}" correctly`, () => { - const res = prepareOutputFilename(given); - - expect(res).to.be.equal(expected); - }); - } -}); - -describe('getBestResponse', () => { - it('handles no responses', () => { - const op: OA3.OperationObject = { - responses: {}, - }; - - const res = getBestResponse(op); - - expect(res).to.be.equal(null); - }); - - it('handles 200 response with text/plain media type', () => { - const op: OA3.OperationObject = { - responses: { - '200': { - description: 'Success', - content: { - 'text/plain': { - schema: { - $ref: '#/components/schemas/TestObject', - }, - }, - }, - }, - }, - }; - - const res = getBestResponse(op); - - expect(res).to.be.eql({ - schema: { - $ref: '#/components/schemas/TestObject', - }, - }); - }); - - it('handles 201 response with unsupported media type', () => { - const op: OA3.OperationObject = { - responses: { - '201': { - description: 'Success', - content: { - 'application/octet-stream': { - schema: { - $ref: '#/components/schemas/TestObject', - }, - }, - }, - }, - }, - }; - - const res = getBestResponse(op); - - expect(res).to.be.eql(null); - }); - - it('handles multiple responses', () => { - const op: OA3.OperationObject = { - responses: { - '301': { - description: 'Moved Permanently', - content: { - 'text/plain': { - schema: { - $ref: '#/components/schemas/Wrong', - }, - }, - }, - }, - '203': { - description: 'Success', - content: { - 'text/plain': { - schema: { - $ref: '#/components/schemas/TestObject', - }, - }, - }, - }, - }, - }; - - const res = getBestResponse(op); - - expect(res).to.be.eql({ - schema: { - $ref: '#/components/schemas/TestObject', - }, - }); - }); -}); diff --git a/src/gen/util.ts b/src/gen/util.ts deleted file mode 100644 index 32bf9be..0000000 --- a/src/gen/util.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { type Stats, lstatSync, mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; -import { dirname } from 'node:path'; -import type { OpenAPIV3 as OA3 } from 'openapi-types'; - -import type { ApiOperation } from '../types'; - -export function exists(filePath: string): Stats { - try { - return lstatSync(filePath); - } catch (e) { - return undefined; - } -} - -export function saveFile(filePath: string, contents: string) { - return new Promise((resolve, reject) => { - mkdir(dirname(filePath), { recursive: true }, (err) => { - if (err) { - reject(err); - } - - fsWriteFileSync(filePath, contents); - resolve(true); - }); - }); -} - -/** - * Operations list contains tags, which can be used to group them. - * The grouping allows us to generate multiple client classes dedicated - * to a specific group of operations. - */ -export function groupOperationsByGroupName(operations: ApiOperation[]) { - if (!operations) { - return {}; - } - - return operations.reduce<{ [key: string]: ApiOperation[] }>((groups, op) => { - if (!groups[op.group]) { - groups[op.group] = []; - } - groups[op.group].push(op); - return groups; - }, {}); -} - -/** - * Operations in OpenAPI can have multiple responses, but - * we are interested in the one that is the most common for - * a standard success response. And we need the content of it. - * Content is per media type and we need to choose only one. - * We will try to get the first one that is JSON or plain text. - * Other media types are not supported at this time. - * @returns Response or reference of the success response - */ -export function getBestResponse(op: OA3.OperationObject) { - const NOT_FOUND = 100000; - const lowestCode = Object.keys(op.responses).sort().shift() ?? NOT_FOUND; - - const resp = lowestCode === NOT_FOUND ? op.responses[0] : op.responses[lowestCode.toString()]; - - if (resp && 'content' in resp) { - return ( - resp.content['application/json'] ?? - resp.content['text/json'] ?? - resp.content['text/plain'] ?? - null - ); - } - return null; -} - -/** This method tries to fix potentially wrong out parameter given from commandline */ -export function prepareOutputFilename(out: string | null): string { - if (!out) { - return null; - } - - if (/\.[jt]sx?$/i.test(out)) { - return out.replace(/[\\]/i, '/'); - } - if (/[\/\\]$/i.test(out)) { - return `${out.replace(/[\/\\]$/i, '')}/index.ts`; - } - return `${out.replace(/[\\]/i, '/')}.ts`; -} - -export function orderBy(arr: T[] | null | undefined, key: string) { - if (!arr) { - return []; - } - - return arr.concat().sort(sortByKey(key)); -} - -const sortByKey = (key: string) => (a, b) => a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0; - -export function upperFirst(str?: string | null) { - return str ? str.charAt(0).toUpperCase() + str.slice(1) : ''; -} diff --git a/src/index.ts b/src/index.ts index f20077a..dd4757c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,9 @@ import fs from 'node:fs'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import genJsCode from './gen/js'; -import { loadAllTemplateFiles } from './gen/templateManager'; +import genJsCode from './gen'; import type { ClientOptions, FullAppOptions } from './types'; -import { loadSpecDocument, verifyDocumentSpec } from './utils'; +import { loadSpecDocument, verifyDocumentSpec, loadAllTemplateFiles } from './utils'; /** * Runs the whole code generation process. diff --git a/src/types.ts b/src/types.ts index 10c4726..2bded3c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,45 +26,6 @@ export type Template = 'axios' | 'fetch' | 'ng1' | 'ng2' | 'swr-axios' | 'xior'; export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch'; export type DateSupport = 'string' | 'Date'; // 'luxon', 'momentjs', etc -type CollectionFormat = 'csv' | 'ssv' | 'tsv' | 'pipes' | 'multi'; - -export interface ApiOperationParamBase { - type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'file'; - format: - | 'int32' - | 'int64' - | 'float' - | 'double' - | 'byte' - | 'binary' - | 'date' - | 'date-time' - | 'password'; - items: ApiOperationParamBase; - collectionFormat: CollectionFormat; - default: any; - maximum: number; - exclusiveMaximum: boolean; - minimum: number; - exclusiveMinimum: boolean; - maxLength: number; - minLength: number; - pattern: string; - maxItems: number; - minItems: number; - uniqueItems: boolean; - enum: any[]; - multipleOf: number; -} - -export interface ApiOperationParamGroups { - header?: any; - path?: any; - query?: any; - formData?: any; - body?: any; -} - /** * Local type that represent Operation as understood by Swaggie **/ @@ -73,16 +34,3 @@ export interface ApiOperation extends OA3.OperationObject { path: string; group: string; } - -export interface ApiOperationResponse { - code: string; - description: string; - schema: object; - headers: object; - examples: object; -} - -export interface ApiOperationSecurity { - id: string; - scopes?: string[]; -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 08d3b03..71c48cc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './utils'; export * from './documentLoader'; export * from './test.utils'; +export * from './templateManager'; diff --git a/src/gen/js/serializeQueryParam.angular1.spec.ts b/src/utils/serializeQueryParam.angular1.spec.ts similarity index 100% rename from src/gen/js/serializeQueryParam.angular1.spec.ts rename to src/utils/serializeQueryParam.angular1.spec.ts diff --git a/src/gen/js/serializeQueryParam.spec.ts b/src/utils/serializeQueryParam.spec.ts similarity index 100% rename from src/gen/js/serializeQueryParam.spec.ts rename to src/utils/serializeQueryParam.spec.ts diff --git a/src/gen/templateManager.spec.ts b/src/utils/templateManager.spec.ts similarity index 100% rename from src/gen/templateManager.spec.ts rename to src/utils/templateManager.spec.ts diff --git a/src/gen/templateManager.ts b/src/utils/templateManager.ts similarity index 91% rename from src/gen/templateManager.ts rename to src/utils/templateManager.ts index 1dfbeb0..a6bce3e 100644 --- a/src/gen/templateManager.ts +++ b/src/utils/templateManager.ts @@ -21,6 +21,9 @@ export function loadAllTemplateFiles(templateName: string | null) { engine = new Eta({ views: templatesDir }); } +/** + * Get's a template file and renders it with the provided data. + */ export function renderFile(templateFile: string, data: object = {}) { return engine.render(templateFile, data); } diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index 44d12d6..de59869 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -1,30 +1,31 @@ import { expect } from 'chai'; -import { type VerifableDocument, escapeReservedWords, verifyDocumentSpec } from './utils'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; +import { + type VerifableDocument, + escapeReservedWords, + verifyDocumentSpec, + groupOperationsByGroupName, + getBestResponse, + prepareOutputFilename, +} from './utils'; +import type { ApiOperation } from '../types'; describe('escapeReservedWords', () => { - it('handles null', () => { - const res = escapeReservedWords(null); + const testCases = [ + { input: '', expected: '' }, + { input: null, expected: null }, + { input: undefined, expected: undefined }, + { input: 'Burrito', expected: 'Burrito' }, + { input: 'return', expected: '_return' }, + ]; - expect(res).to.be.eql(null); - }); - - it('handles empty string', () => { - const res = escapeReservedWords(''); - - expect(res).to.be.eql(''); - }); - - it('handles safe word', () => { - const res = escapeReservedWords('Burrito'); - - expect(res).to.be.eql('Burrito'); - }); - - it('handles reserved word', () => { - const res = escapeReservedWords('return'); + for (const { input, expected } of testCases) { + it(`should escape ${input} correctly`, async () => { + const res = escapeReservedWords(input); - expect(res).to.be.eql('_return'); - }); + expect(res).to.equal(expected); + }); + } }); describe('verifyDocumentSpec', () => { @@ -43,7 +44,7 @@ describe('verifyDocumentSpec', () => { }).to.not.throw(); }); - it('should reject Swagger document', () => { + it('should reject Swagger 2.0 document', () => { expect(() => { const res = verifyDocumentSpec({ swagger: '2.0', @@ -53,7 +54,7 @@ describe('verifyDocumentSpec', () => { }).to.throw('not supported'); }); - it('should reject empty document', () => { + it('should reject an empty document', () => { expect(() => { const res = verifyDocumentSpec(null as any); @@ -61,3 +62,276 @@ describe('verifyDocumentSpec', () => { }).to.throw('is empty'); }); }); + +describe('groupOperationsByGroupName', () => { + const testCases = [ + { input: [], expected: {} }, + { input: null, expected: {} }, + { input: undefined, expected: {} }, + ]; + for (const { input, expected } of testCases) { + it(`should handle ${input} as input`, async () => { + const res = groupOperationsByGroupName(input); + + expect(res).to.deep.equal(expected); + }); + } + + it('handles single operation', async () => { + const def: ApiOperation[] = [ + { + operationId: 'HealthCheck_PerformAllChecks', + method: 'get', + group: 'HealthCheck', + path: '/healthcheck', + parameters: [ + { + in: 'query', + name: 'token', + required: false, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Success', + }, + }, + tags: ['HealthCheck'], + }, + ]; + + const res = groupOperationsByGroupName(def); + + expect(res).to.be.ok; + expect(res.HealthCheck).to.be.ok; + expect(res.HealthCheck.length).to.eq(1); + }); + + it('handles two different operations and the same group', async () => { + const def: ApiOperation[] = [ + { + operationId: 'HealthCheck_PerformAllChecks', + method: 'get', + group: 'HealthCheck', + path: '/healthcheck', + parameters: [ + { + in: 'query', + name: 'token', + required: false, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Success', + }, + }, + tags: ['HealthCheck'], + }, + { + operationId: 'HealthCheck_SomethingElse', + method: 'post', + group: 'HealthCheck', + path: '/healthcheck', + parameters: [ + { + in: 'query', + name: 'token', + required: false, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Success', + }, + }, + tags: ['HealthCheck'], + }, + ]; + + const res = groupOperationsByGroupName(def); + + expect(res).to.be.ok; + expect(res.HealthCheck).to.be.ok; + expect(res.HealthCheck.length).to.eq(2); + }); + + it('handles two different operations and different groups', async () => { + const def: ApiOperation[] = [ + { + operationId: 'HealthCheck_PerformAllChecks', + method: 'get', + group: 'HealthCheck', + path: '/healthcheck', + parameters: [ + { + in: 'query', + name: 'token', + required: false, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Success', + }, + }, + tags: ['HealthCheck'], + }, + { + operationId: 'Illness_SomethingElse', + method: 'get', + group: 'Illness', + path: '/illness', + parameters: [ + { + in: 'query', + name: 'token', + required: false, + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Success', + }, + }, + tags: ['Illness'], + }, + ]; + + const res = groupOperationsByGroupName(def); + + expect(res).to.be.ok; + expect(res.HealthCheck).to.be.ok; + expect(res.HealthCheck.length).to.eq(1); + expect(res.Illness).to.be.ok; + expect(res.Illness.length).to.eq(1); + }); +}); + +describe('prepareOutputFilename', () => { + for (const { given, expected } of [ + { given: null, expected: null }, + { given: 'api.ts', expected: 'api.ts' }, + { given: 'api', expected: 'api.ts' }, + { given: 'api/', expected: 'api/index.ts' }, + { given: 'api\\', expected: 'api/index.ts' }, + { given: 'api/api.ts', expected: 'api/api.ts' }, + { given: 'api//api.ts', expected: 'api//api.ts' }, + { given: 'api\\api.ts', expected: 'api/api.ts' }, + { given: 'api/api/', expected: 'api/api/index.ts' }, + ]) { + it(`handles "${given}" correctly`, () => { + const res = prepareOutputFilename(given); + + expect(res).to.be.equal(expected); + }); + } +}); + +describe('getBestResponse', () => { + it('handles no responses', () => { + const op: OA3.OperationObject = { + responses: {}, + }; + + const res = getBestResponse(op); + + expect(res).to.be.equal(null); + }); + + it('handles 200 response with text/plain media type', () => { + const op: OA3.OperationObject = { + responses: { + '200': { + description: 'Success', + content: { + 'text/plain': { + schema: { + $ref: '#/components/schemas/TestObject', + }, + }, + }, + }, + }, + }; + + const res = getBestResponse(op); + + expect(res).to.be.eql({ + schema: { + $ref: '#/components/schemas/TestObject', + }, + }); + }); + + it('handles 201 response with unsupported media type', () => { + const op: OA3.OperationObject = { + responses: { + '201': { + description: 'Success', + content: { + 'application/octet-stream': { + schema: { + $ref: '#/components/schemas/TestObject', + }, + }, + }, + }, + }, + }; + + const res = getBestResponse(op); + + expect(res).to.be.eql(null); + }); + + it('handles multiple responses', () => { + const op: OA3.OperationObject = { + responses: { + '301': { + description: 'Moved Permanently', + content: { + 'text/plain': { + schema: { + $ref: '#/components/schemas/Wrong', + }, + }, + }, + }, + '203': { + description: 'Success', + content: { + 'text/plain': { + schema: { + $ref: '#/components/schemas/TestObject', + }, + }, + }, + }, + }, + }; + + const res = getBestResponse(op); + + expect(res).to.be.eql({ + schema: { + $ref: '#/components/schemas/TestObject', + }, + }); + }); +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index f4413a8..effa5db 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,8 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; +import { mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import type { ApiOperation } from '../types'; const reservedWords = [ 'break', @@ -63,3 +67,86 @@ export interface VerifableDocument extends OA3.Document { swagger?: string; openapi: string; } + +export function saveFile(filePath: string, contents: string) { + return new Promise((resolve, reject) => { + mkdir(dirname(filePath), { recursive: true }, (err) => { + if (err) { + reject(err); + } + + fsWriteFileSync(filePath, contents); + resolve(true); + }); + }); +} + +/** + * Operations list contains tags, which can be used to group them. + * The grouping allows us to generate multiple client classes dedicated + * to a specific group of operations. + */ +export function groupOperationsByGroupName(operations: ApiOperation[]) { + if (!operations) { + return {}; + } + + return operations.reduce<{ [key: string]: ApiOperation[] }>((groups, op) => { + if (!groups[op.group]) { + groups[op.group] = []; + } + groups[op.group].push(op); + return groups; + }, {}); +} + +/** + * Operations in OpenAPI can have multiple responses, but + * we are interested in the one that is the most common for + * a standard success response. And we need the content of it. + * Content is per media type and we need to choose only one. + * We will try to get the first one that is JSON or plain text. + * Other media types are not supported at this time. + * @returns Response or reference of the success response + */ +export function getBestResponse(op: OA3.OperationObject) { + const NOT_FOUND = 100000; + const lowestCode = Object.keys(op.responses).sort().shift() ?? NOT_FOUND; + + const resp = lowestCode === NOT_FOUND ? op.responses[0] : op.responses[lowestCode.toString()]; + + if (resp && 'content' in resp) { + return ( + resp.content['application/json'] ?? + resp.content['text/json'] ?? + resp.content['text/plain'] ?? + null + ); + } + return null; +} + +/** This method tries to fix potentially wrong out parameter given from commandline */ +export function prepareOutputFilename(out: string | null): string { + if (!out) { + return null; + } + + if (/\.[jt]sx?$/i.test(out)) { + return out.replace(/[\\]/i, '/'); + } + if (/[\/\\]$/i.test(out)) { + return `${out.replace(/[\/\\]$/i, '')}/index.ts`; + } + return `${out.replace(/[\\]/i, '/')}.ts`; +} + +export function orderBy(arr: T[] | null | undefined, key: string) { + if (!arr) { + return []; + } + + return arr.concat().sort(sortByKey(key)); +} + +const sortByKey = (key: string) => (a, b) => a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0; From 199072741fdf9dae3d4590a592818255c37e5dee Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Thu, 4 Jul 2024 21:23:26 +0200 Subject: [PATCH 12/27] chore: more code cleanup --- .vscode/launch.json | 3 +- src/gen/genOperations.ts | 7 +- src/gen/genTypes.ts | 10 +- src/gen/index.ts | 9 +- src/index.ts | 4 +- src/swagger/index.ts | 5 +- src/swagger/operations.spec.ts | 3 +- .../typesExtractor.spec.ts} | 174 +++++------------- .../support.ts => swagger/typesExtractor.ts} | 10 + src/types.ts | 2 +- src/utils/documentLoader.ts | 3 + src/utils/templateManager.spec.ts | 1 + src/utils/utils.spec.ts | 1 + src/utils/utils.ts | 2 +- tsconfig.json | 2 +- 15 files changed, 89 insertions(+), 147 deletions(-) rename src/{gen/support.spec.ts => swagger/typesExtractor.spec.ts} (67%) rename src/{gen/support.ts => swagger/typesExtractor.ts} (92%) diff --git a/.vscode/launch.json b/.vscode/launch.json index bb286bc..f59945e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,8 +10,7 @@ "name": "Swaggie", "program": "${workspaceFolder}/src/cli.ts", "cwd": "${workspaceFolder}", - "runtimeArgs": ["-r", "sucrase/register"], - "protocol": "inspector", + "runtimeArgs": ["-r", "@swc/register"], "args": ["-c", "./test/sample-config.json"] } ] diff --git a/src/gen/genOperations.ts b/src/gen/genOperations.ts index 78ceca8..efd1222 100644 --- a/src/gen/genOperations.ts +++ b/src/gen/genOperations.ts @@ -1,14 +1,17 @@ import { camel } from 'case'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import { getParameterType } from './support'; +import { getParameterType } from '../swagger'; import { groupOperationsByGroupName, getBestResponse, orderBy, renderFile } from '../utils'; import { generateBarrelFile } from './createBarrel'; import type { ApiOperation, ClientOptions } from '../types'; import { escapeReservedWords } from '../utils'; import { getOperations } from '../swagger'; -export default async function genOperations( +/** + * Function that will analyze paths in the spec and generate the code for all the operations. + */ +export default async function generateOperations( spec: OA3.Document, options: ClientOptions ): Promise { diff --git a/src/gen/genTypes.ts b/src/gen/genTypes.ts index 8a6f699..a681df9 100644 --- a/src/gen/genTypes.ts +++ b/src/gen/genTypes.ts @@ -1,13 +1,13 @@ import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; -import { getCompositeTypes, getTypeFromSchema } from './support'; +import { getCompositeTypes, getTypeFromSchema } from '../swagger'; import type { ClientOptions } from '../types'; /** - * Generates TypeScript types for the given OpenAPI 3 document. + * Generates TypeScript code with all the types for the given OpenAPI 3 document. * @returns String containing all TypeScript types in the document. */ -export default function genTypes(spec: OA3.Document, options: ClientOptions): string { +export default function generateTypes(spec: OA3.Document, options: ClientOptions): string { const result: string[] = []; const schemaKeys = Object.keys(spec.components?.schemas || {}); @@ -98,7 +98,7 @@ function generateObjectTypeContents(schema: OA3.SchemaObject, options: ClientOpt for (const prop of props) { const propDefinition = schema.properties[prop]; const isRequired = !!~required.indexOf(prop); - result.push(renderTsTypeProp(prop, propDefinition, isRequired, options)); + result.push(renderTypeProp(prop, propDefinition, isRequired, options)); } return result.join('\n'); @@ -153,7 +153,7 @@ function renderOpenApi31Enum(name: string, def: OA31.SchemaObject) { return `${res}}\n`; } -function renderTsTypeProp( +function renderTypeProp( prop: string, definition: OA3.ReferenceObject | OA3.SchemaObject, required: boolean, diff --git a/src/gen/index.ts b/src/gen/index.ts index c4574d4..d5e54d5 100644 --- a/src/gen/index.ts +++ b/src/gen/index.ts @@ -1,12 +1,15 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import genOperations from './genOperations'; +import generateOperations from './genOperations'; import genTypes from './genTypes'; import { saveFile, prepareOutputFilename } from '../utils'; import type { ClientOptions } from '../types'; -export default async function genCode(spec: OA3.Document, options: ClientOptions): Promise { - let fileContents = await genOperations(spec, options); +export default async function generateCode( + spec: OA3.Document, + options: ClientOptions +): Promise { + let fileContents = await generateOperations(spec, options); fileContents += genTypes(spec, options); if (options.out) { diff --git a/src/index.ts b/src/index.ts index dd4757c..872fbf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; -import genJsCode from './gen'; +import generateCode from './gen'; import type { ClientOptions, FullAppOptions } from './types'; import { loadSpecDocument, verifyDocumentSpec, loadAllTemplateFiles } from './utils'; @@ -35,7 +35,7 @@ function verifyOptions(options: Partial) { function gen(spec: OA3.Document, options: ClientOptions): Promise { loadAllTemplateFiles(options.template || 'axios'); - return genJsCode(spec, options); + return generateCode(spec, options); } export async function applyConfigFile(options: Partial): Promise { diff --git a/src/swagger/index.ts b/src/swagger/index.ts index c55b1e3..f2c1de4 100644 --- a/src/swagger/index.ts +++ b/src/swagger/index.ts @@ -1,3 +1,2 @@ -import { getOperations } from './operations'; - -export { getOperations }; +export * from './operations'; +export * from './typesExtractor'; diff --git a/src/swagger/operations.spec.ts b/src/swagger/operations.spec.ts index e92f54f..4aff6eb 100644 --- a/src/swagger/operations.spec.ts +++ b/src/swagger/operations.spec.ts @@ -1,8 +1,9 @@ import { expect } from 'chai'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; + import { getOperations } from './operations'; import { getDocument } from '../utils'; import type { ApiOperation } from '../types'; -import type { OpenAPIV3 as OA3 } from 'openapi-types'; describe('getOperations', () => { it('should handle empty operation list', () => { diff --git a/src/gen/support.spec.ts b/src/swagger/typesExtractor.spec.ts similarity index 67% rename from src/gen/support.spec.ts rename to src/swagger/typesExtractor.spec.ts index 5e56974..ba047a3 100644 --- a/src/gen/support.spec.ts +++ b/src/swagger/typesExtractor.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import type { ClientOptions } from '../types'; -import { getParameterType, getTypeFromSchema } from './support'; +import { getParameterType, getTypeFromSchema } from './typesExtractor'; import { getClientOptions } from '../utils'; describe('getParameterType', () => { @@ -34,24 +34,7 @@ describe('getParameterType', () => { } }); - it('file', async () => { - const param: OA3.ParameterObject = { - name: 'attachment', - in: 'body', - required: false, - schema: { - type: 'string', - format: 'binary', - }, - }; - const options = {}; - - const res = getParameterType(param, options); - - expect(res).to.be.equal('File'); - }); - - it('array with a reference type', async () => { + it('standard case', async () => { const param: OA3.ParameterObject = { name: 'items', in: 'query', @@ -62,115 +45,11 @@ describe('getParameterType', () => { }, }, }; - - const res = getParameterType(param, {}); - - expect(res).to.be.equal('Item[]'); - }); - - it('reference #1', async () => { - const param: OA3.ParameterObject = { - name: 'something', - in: 'body', - required: false, - schema: { - $ref: '#/components/schemas/SomeItem', - }, - }; const options = {}; const res = getParameterType(param, options); - expect(res).to.be.equal('SomeItem'); - }); - - it('inline enums', async () => { - const param: OA3.ParameterObject = { - name: 'Roles', - in: 'query', - schema: { - type: 'array', - items: { - enum: ['Admin', 'User', 'Guest'], - type: 'string', - }, - }, - }; - const options = {}; - - const res = getParameterType(param, options); - - expect(res).to.be.equal(`("Admin" | "User" | "Guest")[]`); - }); - - describe('responses', () => { - it('string', async () => { - const param: OA3.ParameterObject = { - name: 'title', - in: 'query', - required: false, - schema: { - type: 'string', - }, - }; - const options = {}; - - const res = getParameterType(param, options); - - expect(res).to.be.equal('string'); - }); - - it('date', async () => { - const param: OA3.ParameterObject = { - name: 'dateFrom', - in: 'query', - required: false, - schema: { - type: 'string', - format: 'date-time', - }, - }; - const options = {}; - - const res = getParameterType(param, options); - - expect(res).to.be.equal('Date'); - }); - - it('date with dateFormatter = string', async () => { - const param: OA3.ParameterObject = { - name: 'dateFrom', - in: 'query', - required: false, - schema: { - type: 'string', - format: 'date-time', - }, - }; - const options = { dateFormat: 'string' } as ClientOptions; - - const res = getParameterType(param, options); - - expect(res).to.be.equal('string'); - }); - - it('array > reference', async () => { - const param: OA3.ParameterObject = { - name: 'items', - in: 'query', - schema: { - type: 'array', - items: { - $ref: '#/components/schemas/Item', - }, - }, - }; - const options = {}; - - const res = getParameterType(param, options); - - expect(res).to.be.equal('Item[]'); - }); + expect(res).to.be.equal('Item[]'); }); }); @@ -193,6 +72,16 @@ describe('getTypeFromSchema', () => { { schema: { type: 'array', items: { type: 'number' } }, expected: 'number[]' }, { schema: { type: 'array', items: { type: 'boolean' } }, expected: 'boolean[]' }, { schema: { type: 'array', items: { type: 'object' } }, expected: 'unknown[]' }, + { + schema: { + type: 'array', + items: { + enum: ['Admin', 'User', 'Guest'], + type: 'string', + }, + }, + expected: '("Admin" | "User" | "Guest")[]', + }, ]; for (const { schema, expected } of testCases) { @@ -203,7 +92,7 @@ describe('getTypeFromSchema', () => { }); } - it('should process array of objects correctly', () => { + it('should process array of inline objects correctly', () => { const schema: OA3.SchemaObject = { type: 'array', items: { @@ -222,6 +111,36 @@ describe('getTypeFromSchema', () => { id: number; }[]`); }); + + it('should process array of arrays correctly', () => { + const schema: OA3.SchemaObject = { + type: 'array', + items: { + type: 'array', + items: { + type: 'number', + }, + }, + }; + const res = getTypeFromSchema(schema, opts); + + expect(res).to.equalWI('number[][]'); + }); + + it('should process array of arrays with objects correctly', () => { + const schema: OA3.SchemaObject = { + type: 'array', + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Item', + }, + }, + }; + const res = getTypeFromSchema(schema, opts); + + expect(res).to.equalWI('Item[][]'); + }); }); describe('objects', () => { @@ -277,7 +196,7 @@ describe('getTypeFromSchema', () => { describe('basic types', () => { type TestCase = { - schema: OA3.SchemaObject; + schema: OA3.SchemaObject | OA3.ReferenceObject; expected: string; }; @@ -289,6 +208,9 @@ describe('getTypeFromSchema', () => { { schema: { type: 'number' }, expected: 'number' }, { schema: { type: 'integer' }, expected: 'number' }, { schema: { type: 'boolean' }, expected: 'boolean' }, + { schema: { $ref: '' }, expected: '' }, + { schema: { $ref: '#/components/' }, expected: '' }, + { schema: { $ref: '#/components/schema/Test' }, expected: 'Test' }, { schema: null, expected: 'unknown' }, { schema: undefined, expected: 'unknown' }, { schema: {}, expected: 'unknown' }, diff --git a/src/gen/support.ts b/src/swagger/typesExtractor.ts similarity index 92% rename from src/gen/support.ts rename to src/swagger/typesExtractor.ts index 5d7d930..5b3f545 100644 --- a/src/gen/support.ts +++ b/src/swagger/typesExtractor.ts @@ -26,6 +26,12 @@ export function getParameterType( return getTypeFromSchema(param.schema, options); } +/** + * Converts a schema object (or a reference) to a TypeScript type. + * @example + * { type: 'number', format: 'int32' } -> 'number' + * { $ref: '#/components/schema/User' } -> 'User' + */ export function getTypeFromSchema( schema: OA3.SchemaObject | OA3.ReferenceObject, options: Partial @@ -71,6 +77,10 @@ export function getTypeFromSchema( return unknownType; } +/** + * Knowing that the schema is an object, this function returns a TypeScript type definition + * for given schema object. + */ function getTypeFromObject(schema: OA3.SchemaObject, options: Partial): string { const unknownType = options.preferAny ? 'any' : 'unknown'; diff --git a/src/types.ts b/src/types.ts index 2bded3c..dff8dd7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,7 @@ export interface ClientOptions { src: string | object; /** Path to the file which will contain generated TypeScript code */ out?: string; - /** Template to be used for generation */ + /** Template to be used for code generation */ template: Template; baseUrl?: string; preferAny?: boolean; diff --git a/src/utils/documentLoader.ts b/src/utils/documentLoader.ts index a9aede5..5811d78 100644 --- a/src/utils/documentLoader.ts +++ b/src/utils/documentLoader.ts @@ -3,6 +3,9 @@ import YAML from 'js-yaml'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { request } from 'undici'; +/** + * Function that loads an OpenAPI document from a path or URL + */ export async function loadSpecDocument(src: string | object): Promise { if (typeof src === 'string') { return await loadFile(src); diff --git a/src/utils/templateManager.spec.ts b/src/utils/templateManager.spec.ts index e71ce91..cf0c5dd 100644 --- a/src/utils/templateManager.spec.ts +++ b/src/utils/templateManager.spec.ts @@ -2,6 +2,7 @@ import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; import { expect } from 'chai'; + import { loadAllTemplateFiles, renderFile } from './templateManager'; const GOOD_FILE = 'client.ejs'; diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index de59869..86f4987 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; + import { type VerifableDocument, escapeReservedWords, diff --git a/src/utils/utils.ts b/src/utils/utils.ts index effa5db..c8b874a 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,6 +1,6 @@ -import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { mkdir, writeFileSync as fsWriteFileSync } from 'node:fs'; import { dirname } from 'node:path'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; import type { ApiOperation } from '../types'; diff --git a/tsconfig.json b/tsconfig.json index c59997a..c19bfad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./", - "target": "es2017", + "target": "ES2021", "module": "commonjs", "declaration": true, "removeComments": false, From e9f538bd56f44f86ddb1f7b5b0faeb868e30ac0a Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Fri, 5 Jul 2024 00:49:53 +0200 Subject: [PATCH 13/27] feat: support form-data and urlencoded content types --- biome.json | 2 +- src/gen/createBarrel.ts | 1 + src/gen/genOperations.spec.ts | 128 +++++++++++++++++++++++++++++- src/gen/genOperations.ts | 68 +++++++++++----- src/gen/genTypes.spec.ts | 36 ++++----- src/gen/index.ts | 4 +- templates/axios/operation.ejs | 20 ++--- templates/fetch/operation.ejs | 6 ++ templates/ng1/operation.ejs | 24 +----- templates/ng2/operation.ejs | 18 ++--- templates/swr-axios/operation.ejs | 20 ++--- templates/xior/operation.ejs | 20 ++--- test/petstore-v3.yml | 14 +--- test/snapshots/axios.ts | 9 ++- test/snapshots/fetch.ts | 9 ++- test/snapshots/ng1.ts | 60 +++++++------- test/snapshots/ng2.ts | 8 +- test/snapshots/swr-axios.ts | 9 ++- test/snapshots/xior.ts | 9 ++- 19 files changed, 288 insertions(+), 177 deletions(-) diff --git a/biome.json b/biome.json index 9a8f5f0..13ed821 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json", + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", "formatter": { "enabled": true, "formatWithErrors": false, diff --git a/src/gen/createBarrel.ts b/src/gen/createBarrel.ts index e6134dd..98096c7 100644 --- a/src/gen/createBarrel.ts +++ b/src/gen/createBarrel.ts @@ -1,4 +1,5 @@ import { camel } from 'case'; + import type { ApiOperation, ClientOptions } from '../types'; import { renderFile } from '../utils'; diff --git a/src/gen/genOperations.spec.ts b/src/gen/genOperations.spec.ts index 2e220f2..3ab2074 100644 --- a/src/gen/genOperations.spec.ts +++ b/src/gen/genOperations.spec.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; +import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { prepareOperations, fixDuplicateOperations, getOperationName } from './genOperations'; import type { ApiOperation } from '../types'; import { getClientOptions } from '../utils'; -import type { OpenAPIV3 as OA3 } from 'openapi-types'; describe('prepareOperations', () => { const opts = getClientOptions(); @@ -105,7 +105,7 @@ describe('prepareOperations', () => { expect(op2.parameters).to.deep.equal([]); }); - describe('requestBody', () => { + describe('requestBody (JSON)', () => { it('should handle requestBody with ref type', () => { const ops: ApiOperation[] = [ { @@ -129,6 +129,7 @@ describe('prepareOperations', () => { const [op1] = prepareOperations(ops, opts); const expectedBodyParam = { + contentType: 'json', name: 'body', optional: false, originalName: 'body', @@ -205,6 +206,7 @@ describe('prepareOperations', () => { const [op1] = prepareOperations(ops, opts); const expectedBodyParam = { + contentType: 'json', name: 'body', optional: true, originalName: 'body', @@ -251,6 +253,7 @@ describe('prepareOperations', () => { const [op1] = prepareOperations(ops, opts); const expectedBodyParam = { + contentType: 'json', name: 'petBody', optional: false, originalName: 'pet-body', @@ -334,6 +337,127 @@ describe('prepareOperations', () => { ]); }); }); + + describe('requestBody (x-www-form-urlencoded)', () => { + it('should handle requestBody with ref type', () => { + const ops: ApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet', + requestBody: { + required: true, + content: { + 'application/x-www-form-urlencoded': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + contentType: 'urlencoded', + name: 'body', + optional: false, + originalName: 'body', + type: 'Pet', + original: ops[0].requestBody, + }; + + expect(op1.body).to.deep.equal(expectedBodyParam); + expect(op1.parameters).to.deep.equal([expectedBodyParam]); + }); + }); + + describe('requestBody (application/octet-stream)', () => { + it('should handle File request body', () => { + const ops: ApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet', + requestBody: { + required: true, + content: { + 'application/octet-stream': { + schema: { + type: 'string', + format: 'binary', + }, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + contentType: 'binary', + name: 'body', + optional: false, + originalName: 'body', + type: 'File', + original: ops[0].requestBody, + }; + + expect(op1.body).to.deep.equal(expectedBodyParam); + expect(op1.parameters).to.deep.equal([expectedBodyParam]); + }); + }); + + describe('requestBody (multipart/form-data)', () => { + it('should handle form data', () => { + const ops: ApiOperation[] = [ + { + operationId: 'createPet', + method: 'post', + path: '/pet', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + name: { + type: 'string', + }, + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }, + }, + }, + responses: {}, + group: null, + }, + ]; + + const [op1] = prepareOperations(ops, opts); + const expectedBodyParam = { + contentType: 'form-data', + name: 'body', + optional: false, + originalName: 'body', + type: 'FormData', + original: ops[0].requestBody, + }; + + expect(op1.body).to.deep.equal(expectedBodyParam); + expect(op1.parameters).to.deep.equal([expectedBodyParam]); + }); + }); }); }); diff --git a/src/gen/genOperations.ts b/src/gen/genOperations.ts index efd1222..cf231e4 100644 --- a/src/gen/genOperations.ts +++ b/src/gen/genOperations.ts @@ -155,15 +155,6 @@ function getParams( })); } -export function renderOperationGroup( - group: any[], - func: any, - spec: OA3.Document, - options: ClientOptions -): string[] { - return group.map((op) => func.call(this, spec, op, options)).reduce((a, b) => a.concat(b)); -} - /** * Escapes param names to more safe form */ @@ -176,29 +167,63 @@ export function getParamName(name: string): string { ); } -function getRequestBody( - reqBody: OA3.ReferenceObject | OA3.RequestBodyObject -): IOperationParam | null { +function getRequestBody(reqBody: OA3.ReferenceObject | OA3.RequestBodyObject): IBodyParam | null { if (reqBody && 'content' in reqBody) { - const bodyContent = - reqBody.content['application/json'] ?? - reqBody.content['text/json'] ?? - reqBody.content['text/plain'] ?? - null; + const [bodyContent, contentType] = getBestContentType(reqBody); + const isFormData = contentType === 'form-data'; if (bodyContent) { return { originalName: reqBody['x-name'] ?? 'body', name: getParamName(reqBody['x-name'] ?? 'body'), - type: getParameterType(bodyContent, {}), + type: isFormData ? 'FormData' : getParameterType(bodyContent, {}), optional: !reqBody.required, original: reqBody, + contentType, }; } } return null; } +const orderedContentTypes = [ + 'application/json', + 'text/json', + 'text/plain', + 'application/x-www-form-urlencoded', + 'multipart/form-data', +]; +function getBestContentType(reqBody: OA3.RequestBodyObject): [OA3.MediaTypeObject, MyContentType] { + const contentTypes = Object.keys(reqBody.content); + if (contentTypes.length === 0) { + return [null, null]; + } + + const firstContentType = orderedContentTypes.find((ct) => contentTypes.includes(ct)); + if (firstContentType) { + const typeObject = reqBody.content[firstContentType]; + const type = getContentType(firstContentType); + return [typeObject, type]; + } + + const typeObject = reqBody.content[contentTypes[0]]; + const type = getContentType(contentTypes[0]); + return [typeObject, type]; +} + +function getContentType(type: string) { + if (type === 'application/x-www-form-urlencoded') { + return 'urlencoded'; + } + if (type === 'multipart/form-data') { + return 'form-data'; + } + if (type === 'application/octet-stream') { + return 'binary'; + } + return 'json'; +} + interface ClientData { clientName: string; camelCaseName: string; @@ -214,7 +239,7 @@ interface IOperation { parameters: IOperationParam[]; query: IOperationParam[]; pathParams: IOperationParam[]; - body: IOperationParam; + body: IBodyParam; headers: IOperationParam[]; } @@ -225,3 +250,8 @@ interface IOperationParam { optional: boolean; original: OA3.ParameterObject | OA3.RequestBodyObject; } + +interface IBodyParam extends IOperationParam { + contentType?: MyContentType; +} +type MyContentType = 'json' | 'urlencoded' | 'form-data' | 'binary'; diff --git a/src/gen/genTypes.spec.ts b/src/gen/genTypes.spec.ts index 27e56a7..aa08af1 100644 --- a/src/gen/genTypes.spec.ts +++ b/src/gen/genTypes.spec.ts @@ -1,26 +1,26 @@ import { expect } from 'chai'; import type { OpenAPIV3 as OA3, OpenAPIV3_1 as OA31 } from 'openapi-types'; -import genTypes, { renderComment } from './genTypes'; +import generateTypes, { renderComment } from './genTypes'; import { getClientOptions, getDocument } from '../utils'; -describe('genTypes', () => { +describe('generateTypes', () => { const opts = getClientOptions(); it('should handle empty components properly', () => { - const res = genTypes(getDocument({ components: {} }), opts); + const res = generateTypes(getDocument({ components: {} }), opts); expect(res).to.be.equal(''); }); it('should handle empty components schemas properly', () => { - const res = genTypes(getDocument({ components: { schemas: {} } }), opts); + const res = generateTypes(getDocument({ components: { schemas: {} } }), opts); expect(res).to.be.equal(''); }); it('should handle schema with reference only', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ A: { $ref: '#/components/schemas/B', @@ -41,7 +41,7 @@ export interface B {}` describe('enums', () => { it('should handle simple enums correctly', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ SimpleEnum: { type: 'integer', @@ -67,7 +67,7 @@ export type StringEnum = "Active" | "Disabled";` }); it('should handle extended enums correctly', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ XEnums: { type: 'integer', @@ -115,7 +115,7 @@ export enum XEnumsString { }); it('should handle OpenApi 3.1 enums', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ Priority: { type: 'integer', @@ -157,7 +157,7 @@ export enum Size { }); // it("should handle NSwag's enum correctly", () => { - // const res = genTypes( + // const res = generateTypes( // getDocument(), // { // SomeEnum: { @@ -182,7 +182,7 @@ export enum Size { describe('objects', () => { it('should handle obj with no required fields', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { type: 'object', @@ -213,7 +213,7 @@ export interface Empty {} }); it('should handle obj with required fields', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { type: 'object', @@ -250,7 +250,7 @@ export interface AuthenticationData { describe('arrays', () => { it('should handle simple array cases', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ StringArray: { type: 'array', @@ -280,7 +280,7 @@ export type ObjectArray = UserViewModel[]; }); it('should handle different array types as properties', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ ComplexObject: { type: 'object', @@ -340,7 +340,7 @@ export interface ComplexObject { describe('inheritance', () => { describe('allOf', () => { it('should handle 2 allOf correctly (most common case)', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { allOf: [ @@ -366,7 +366,7 @@ export interface AuthenticationData extends BasicAuth { }); it('should handle many allOf correctly', () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { allOf: [ @@ -410,7 +410,7 @@ export interface AuthenticationData extends LoginPart, PasswordPart { for (const type of ['anyOf', 'oneOf']) { describe(type, () => { it(`should handle 1 ${type} with reference correctly`, () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { [type]: [{ $ref: '#/components/schemas/BasicAuth' }], @@ -423,7 +423,7 @@ export interface AuthenticationData extends LoginPart, PasswordPart { }); it(`should handle 2 of ${type} with reference correctly`, () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { [type]: [ @@ -439,7 +439,7 @@ export interface AuthenticationData extends LoginPart, PasswordPart { }); it(`should handle ${type} with reference and schema correctly`, () => { - const res = genTypes( + const res = generateTypes( prepareSchemas({ AuthenticationData: { [type]: [ diff --git a/src/gen/index.ts b/src/gen/index.ts index d5e54d5..2475c02 100644 --- a/src/gen/index.ts +++ b/src/gen/index.ts @@ -1,7 +1,7 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; import generateOperations from './genOperations'; -import genTypes from './genTypes'; +import generateTypes from './genTypes'; import { saveFile, prepareOutputFilename } from '../utils'; import type { ClientOptions } from '../types'; @@ -10,7 +10,7 @@ export default async function generateCode( options: ClientOptions ): Promise { let fileContents = await generateOperations(spec, options); - fileContents += genTypes(spec, options); + fileContents += generateTypes(spec, options); if (options.out) { const destFile = prepareOutputFilename(options.out); diff --git a/templates/axios/operation.ejs b/templates/axios/operation.ejs index 8603446..0e7100b 100644 --- a/templates/axios/operation.ejs +++ b/templates/axios/operation.ejs @@ -14,28 +14,18 @@ $config?: AxiosRequestConfig it.pathParams.forEach((parameter) => { %> url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); <% }); -} %> -<% if(it.formData && it.formData.length > 0) { %> - const formDataBody = new FormData(); - <% it.formData.forEach((parameter) => { %> - if (!!<%= parameter.name %>) { - <% if(parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach((f: any) => formDataBody.append("<%= parameter.originalName %>", f)); - <% } else { %> - formDataBody.append("<%= parameter.originalName %>", <%= parameter.name %><%= parameter.type !== 'string' && parameter.type !== 'File' && parameter.type !== 'Blob' ? '.toString()' : '' %>); - <% } %> - } -<% }); } %> return axios.request<<%~ it.returnType %>>({ url: url, method: '<%= it.method %>', -<% if(it.formData && it.formData.length > 0) { %> - data: formDataBody, -<% } else if(it.body) { %> +<% if(it.body) { %> +<% if(it.body.contentType === 'urlencoded') { %> + data: new URLSearchParams(<%= it.body.name %>), +<% } else { %> data: <%= it.body.name %>, <% } %> +<% } %> <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> diff --git a/templates/fetch/operation.ejs b/templates/fetch/operation.ejs index b67f396..0365044 100644 --- a/templates/fetch/operation.ejs +++ b/templates/fetch/operation.ejs @@ -30,8 +30,14 @@ $config?: RequestInit return fetch(url, { method: '<%= it.method %>', <% if(it.body) { %> +<% if(it.body.contentType === 'binary') { %> + body: <%= it.body.name %>, +<% } else if(it.body.contentType === 'urlencoded') { %> + body: new URLSearchParams(<%= it.body.name %>), +<% } else { %> body: JSON.stringify(<%= it.body.name %>), <% } %> +<% } %> <% if(it.headers && it.headers.length > 0) { %> headers: { <% it.headers.forEach((parameter) => { %> diff --git a/templates/ng1/operation.ejs b/templates/ng1/operation.ejs index e8b2165..d9ab32d 100644 --- a/templates/ng1/operation.ejs +++ b/templates/ng1/operation.ejs @@ -8,11 +8,7 @@ <%= it.name %>(<% it.parameters.forEach((parameter) => { %> <%= parameter.name %>: <%~ parameter.type %> <%= parameter.optional ? ' | null | undefined' : '' %>, <% }); %> -<% if(it.formData && it.formData.length > 0) { %> - config: IRequestShortcutConfig = {headers: {'Content-Type': undefined}} -<% } else { %> config?: IRequestShortcutConfig -<% } %> ): IPromise<<%~ it.returnType %>> { let url = '<%= it.url %>?'; <% if(it.pathParams && it.pathParams.length > 0) { @@ -20,18 +16,6 @@ url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); <% }); } %> -<% if(it.formData && it.formData.length > 0) { %> - const formDataBody = new FormData(); - <% it.formData.forEach((parameter) => { %> - if (!!<%= parameter.name %>) { - <% if(parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach((f: any) => formDataBody.append(`<%= parameter.originalName %>`, f)); - <% } else { %> - formDataBody.append("<%= parameter.originalName %>", <%= parameter.name %><%= parameter.type === 'Date' ? '.toISOString()' : (parameter.type !== 'string' && parameter.type !== 'File' && parameter.type !== 'Blob' ? '.toString()' : '') %>); - <% } %> - } -<% }); -} %> <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> if (<%= parameter.name %> !== undefined) { @@ -47,12 +31,12 @@ return this.$<%= it.method.toLowerCase() %>( url, <% if(['POST', 'PUT', 'PATCH'].includes(it.method)) { %> - <% if(it.formData && it.formData.length > 0) { %> - formDataBody, + <% if(it.body) { %> + <%= it.body.contentType === 'urlencoded' ? 'new URLSearchParams(' + it.body.name + ')' : it.body.name %>, <% } else { %> - <%= it.body ? it.body.name : 'null' %>, + null, <% } %> <% } %> - config + config ); } diff --git a/templates/ng2/operation.ejs b/templates/ng2/operation.ejs index e29e7d7..c650a08 100644 --- a/templates/ng2/operation.ejs +++ b/templates/ng2/operation.ejs @@ -17,18 +17,6 @@ config?: any url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); <% }); } %> -<% if(it.formData && it.formData.length > 0) { %> - const formDataBody = new FormData(); -<% it.formData.forEach((parameter) => { %> - if (!!<%= parameter.name %>) { - <% if(parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach((f: any) => formDataBody.append("<%= parameter.originalName %>", f)); - <% } else { %> - formDataBody.append("<%= parameter.originalName %>", <%= parameter.name %><%= parameter.type !== 'string' && parameter.type !== 'File' && parameter.type !== 'Blob' ? '.toString()' : '' %>); - <% } %> - } -<% }); -} %> <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> if (<%= parameter.name %> !== undefined) { @@ -44,7 +32,11 @@ config?: any return this.$<%= it.method.toLowerCase() %>( url, <% if(['POST', 'PUT', 'PATCH'].includes(it.method)) { %> - <%= it.body ? it.body.name : (it.formData && it.formData.length > 0) ? 'formDataBody' : 'null' %>, +<% if(it.body) { %> + <%= it.body.contentType === 'urlencoded' ? 'new URLSearchParams(' + it.body.name + ')' : it.body.name %>, +<% } else { %> + null, +<% } %> <% } %> config ); diff --git a/templates/swr-axios/operation.ejs b/templates/swr-axios/operation.ejs index c2c96a7..de1f07f 100644 --- a/templates/swr-axios/operation.ejs +++ b/templates/swr-axios/operation.ejs @@ -15,28 +15,18 @@ it.pathParams.forEach((parameter) => { %> url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); <% }); -} %> -<% if(it.formData && it.formData.length > 0) { %> - const formDataBody = new FormData(); - <% it.formData.forEach((parameter) => { %> - if (!!<%= parameter.name %>) { - <% if(parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach((f: any) => formDataBody.append("<%= parameter.originalName %>", f)); - <% } else { %> - formDataBody.append("<%= parameter.originalName %>", <%= parameter.name %><%= parameter.type !== 'string' && parameter.type !== 'File' && parameter.type !== 'Blob' ? '.toString()' : '' %>); - <% } %> - } -<% }); } %> return axios.request<<%~ it.returnType %>>({ url: url, method: '<%= it.method %>', -<% if(it.formData && it.formData.length > 0) { %> - data: formDataBody, -<% } else if(it.body) { %> +<% if(it.body) { %> +<% if(it.body.contentType === 'urlencoded') { %> + data: new URLSearchParams(<%= it.body.name %>), +<% } else { %> data: <%= it.body.name %>, <% } %> +<% } %> <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> diff --git a/templates/xior/operation.ejs b/templates/xior/operation.ejs index b78c87e..1045244 100644 --- a/templates/xior/operation.ejs +++ b/templates/xior/operation.ejs @@ -14,28 +14,18 @@ $config?: XiorRequestConfig it.pathParams.forEach((parameter) => { %> url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); <% }); -} %> -<% if(it.formData && it.formData.length > 0) { %> - const formDataBody = new FormData(); - <% it.formData.forEach((parameter) => { %> - if (!!<%= parameter.name %>) { - <% if(parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach((f: any) => formDataBody.append("<%= parameter.originalName %>", f)); - <% } else { %> - formDataBody.append("<%= parameter.originalName %>", <%= parameter.name %><%= parameter.type !== 'string' && parameter.type !== 'File' && parameter.type !== 'Blob' ? '.toString()' : '' %>); - <% } %> - } -<% }); } %> return http.request<<%~ it.returnType %>>({ url: url, method: '<%= it.method %>', -<% if(it.formData && it.formData.length > 0) { %> - data: formDataBody, -<% } else if(it.body) { %> +<% if(it.body) { %> +<% if(it.body.contentType === 'urlencoded') { %> + data: new URLSearchParams(<%= it.body.name %>), +<% } else { %> data: <%= it.body.name %>, <% } %> +<% } %> <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> diff --git a/test/petstore-v3.yml b/test/petstore-v3.yml index f5271a5..bcd4c29 100644 --- a/test/petstore-v3.yml +++ b/test/petstore-v3.yml @@ -99,12 +99,6 @@ paths: description: Update an existent pet in the store required: true content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/Pet' @@ -577,13 +571,7 @@ paths: requestBody: description: Update an existent user in the store content: - application/json: - schema: - $ref: '#/components/schemas/User' - application/xml: - schema: - $ref: '#/components/schemas/User' - application/x-www-form-urlencoded: + multipart/form-data: schema: $ref: '#/components/schemas/User' delete: diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index 8330c13..3cc1ce0 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -116,7 +116,7 @@ export const petClient = { return axios.request({ url: url, method: 'PUT', - data: body, + data: new URLSearchParams(body), ...$config, }); }, @@ -146,10 +146,12 @@ export const petClient = { }, /** + * @param body (optional) * @param petId * @param additionalMetadata (optional) */ - uploadFile(petId: number , + uploadFile(body: File | null | undefined, + petId: number , additionalMetadata: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { @@ -159,6 +161,7 @@ export const petClient = { return axios.request({ url: url, method: 'POST', + data: body, params: { 'additionalMetadata': serializeQueryParam(additionalMetadata), }, @@ -335,7 +338,7 @@ export const userClient = { * @param body (optional) * @param username */ - updateUser(body: User | null | undefined, + updateUser(body: FormData | null | undefined, username: string , $config?: AxiosRequestConfig ): AxiosPromise { diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index 53d1cda..9708d44 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -108,7 +108,7 @@ export const petClient = { return fetch(url, { method: 'PUT', - body: JSON.stringify(body), + body: new URLSearchParams(body), ...$config, }).then((response) => response.json() as Promise); }, @@ -139,10 +139,12 @@ export const petClient = { }, /** + * @param body (optional) * @param petId * @param additionalMetadata (optional) */ - uploadFile(petId: number , + uploadFile(body: File | null | undefined, + petId: number , additionalMetadata: string | null | undefined, $config?: RequestInit ): Promise { @@ -154,6 +156,7 @@ export const petClient = { return fetch(url, { method: 'POST', + body: body, ...$config, }).then((response) => response.json() as Promise); }, @@ -317,7 +320,7 @@ export const userClient = { * @param body (optional) * @param username */ - updateUser(body: User | null | undefined, + updateUser(body: FormData | null | undefined, username: string , $config?: RequestInit ): Promise { diff --git a/test/snapshots/ng1.ts b/test/snapshots/ng1.ts index 29744d0..5652728 100644 --- a/test/snapshots/ng1.ts +++ b/test/snapshots/ng1.ts @@ -139,8 +139,8 @@ export class petService extends BaseService { return this.$post( url, - body, - config + body, + config ); } @@ -158,7 +158,7 @@ export class petService extends BaseService { return this.$delete( url, - config + config ); } @@ -176,7 +176,7 @@ export class petService extends BaseService { return this.$get( url, - config + config ); } @@ -194,7 +194,7 @@ export class petService extends BaseService { return this.$get( url, - config + config ); } @@ -210,7 +210,7 @@ export class petService extends BaseService { return this.$get( url, - config + config ); } @@ -225,8 +225,8 @@ export class petService extends BaseService { return this.$put( url, - body, - config + new URLSearchParams(body), + config ); } @@ -252,17 +252,19 @@ export class petService extends BaseService { return this.$post( url, - null, - config + null, + config ); } /** + * @param body (optional) * @param petId * @param additionalMetadata (optional) * @return Success */ - uploadFile(petId: number , + uploadFile(body: File | null | undefined, + petId: number , additionalMetadata: string | null | undefined, config?: IRequestShortcutConfig ): IPromise { @@ -274,8 +276,8 @@ export class petService extends BaseService { return this.$post( url, - null, - config + body, + config ); } @@ -299,7 +301,7 @@ export class storeService extends BaseService { return this.$delete( url, - config + config ); } @@ -312,7 +314,7 @@ export class storeService extends BaseService { return this.$get( url, - config + config ); } @@ -328,7 +330,7 @@ export class storeService extends BaseService { return this.$get( url, - config + config ); } @@ -343,8 +345,8 @@ export class storeService extends BaseService { return this.$post( url, - body, - config + body, + config ); } @@ -367,8 +369,8 @@ export class userService extends BaseService { return this.$post( url, - body, - config + body, + config ); } @@ -383,8 +385,8 @@ export class userService extends BaseService { return this.$post( url, - body, - config + body, + config ); } @@ -400,7 +402,7 @@ export class userService extends BaseService { return this.$delete( url, - config + config ); } @@ -416,7 +418,7 @@ export class userService extends BaseService { return this.$get( url, - config + config ); } @@ -439,7 +441,7 @@ export class userService extends BaseService { return this.$get( url, - config + config ); } @@ -452,7 +454,7 @@ export class userService extends BaseService { return this.$get( url, - config + config ); } @@ -461,7 +463,7 @@ export class userService extends BaseService { * @param username * @return Success */ - updateUser(body: User | null | undefined, + updateUser(body: FormData | null | undefined, username: string , config?: IRequestShortcutConfig ): IPromise { @@ -470,8 +472,8 @@ export class userService extends BaseService { return this.$put( url, - body, - config + body, + config ); } diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index d7b671a..96c3af8 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -178,7 +178,7 @@ export class petService extends BaseService { return this.$put( url, - body, + new URLSearchParams(body), config ); } @@ -212,11 +212,13 @@ export class petService extends BaseService { } /** + * @param body (optional) * @param petId * @param additionalMetadata (optional) * @return Success */ uploadFile( + body: File | null | undefined, petId: number, additionalMetadata: string | null | undefined, config?: any @@ -229,7 +231,7 @@ export class petService extends BaseService { return this.$post( url, - null, + body, config ); } @@ -437,7 +439,7 @@ export class userService extends BaseService { * @return Success */ updateUser( - body: User | null | undefined, + body: FormData | null | undefined, username: string, config?: any ): Observable { diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 5934d59..0070964 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -130,7 +130,7 @@ export const petClient = { return axios.request({ url: url, method: 'PUT', - data: body, + data: new URLSearchParams(body), ...$config, }); }, @@ -161,10 +161,12 @@ export const petClient = { }, /** + * @param body (optional) * @param petId * @param additionalMetadata (optional) */ - uploadFile( petId: number , + uploadFile( body: File | null | undefined, + petId: number , additionalMetadata: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { @@ -175,6 +177,7 @@ export const petClient = { return axios.request({ url: url, method: 'POST', + data: body, params: { 'additionalMetadata': serializeQueryParam(additionalMetadata), }, @@ -515,7 +518,7 @@ const { data, error, mutate } = useSWR( * @param body (optional) * @param username */ - updateUser( body: User | null | undefined, + updateUser( body: FormData | null | undefined, username: string , $config?: AxiosRequestConfig ): AxiosPromise { diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 9877aca..68873c5 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -116,7 +116,7 @@ export const petClient = { return http.request({ url: url, method: 'PUT', - data: body, + data: new URLSearchParams(body), ...$config, }); }, @@ -146,10 +146,12 @@ export const petClient = { }, /** + * @param body (optional) * @param petId * @param additionalMetadata (optional) */ - uploadFile(petId: number , + uploadFile(body: File | null | undefined, + petId: number , additionalMetadata: string | null | undefined, $config?: XiorRequestConfig ): Promise> { @@ -159,6 +161,7 @@ export const petClient = { return http.request({ url: url, method: 'POST', + data: body, params: { 'additionalMetadata': serializeQueryParam(additionalMetadata), }, @@ -335,7 +338,7 @@ export const userClient = { * @param body (optional) * @param username */ - updateUser(body: User | null | undefined, + updateUser(body: FormData | null | undefined, username: string , $config?: XiorRequestConfig ): Promise> { From 9c64e39b5b8b0bc39fb7d1fb93c6a9373b3cc52b Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Sat, 6 Jul 2024 00:23:05 +0200 Subject: [PATCH 14/27] impr: better handle response types for fetch fix: type error in generated code for urlencoded content type --- src/gen/genOperations.ts | 54 +++----------- src/utils/utils.spec.ts | 45 +++++++----- src/utils/utils.ts | 56 ++++++++++++-- templates/axios/operation.ejs | 2 +- templates/fetch/operation.ejs | 15 +++- templates/ng1/operation.ejs | 2 +- templates/ng2/operation.ejs | 2 +- templates/swr-axios/operation.ejs | 2 +- templates/xior/operation.ejs | 2 +- test/petstore-v3.yml | 5 +- test/snapshots/axios.ts | 6 +- test/snapshots/fetch.ts | 117 +++++++++++++++++------------- test/snapshots/ng1.ts | 4 +- test/snapshots/ng2.ts | 4 +- test/snapshots/swr-axios.ts | 6 +- test/snapshots/xior.ts | 6 +- 16 files changed, 188 insertions(+), 140 deletions(-) diff --git a/src/gen/genOperations.ts b/src/gen/genOperations.ts index cf231e4..5115797 100644 --- a/src/gen/genOperations.ts +++ b/src/gen/genOperations.ts @@ -2,7 +2,14 @@ import { camel } from 'case'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import { getParameterType } from '../swagger'; -import { groupOperationsByGroupName, getBestResponse, orderBy, renderFile } from '../utils'; +import { + groupOperationsByGroupName, + getBestResponse, + orderBy, + renderFile, + type MyContentType, + getBestContentType, +} from '../utils'; import { generateBarrelFile } from './createBarrel'; import type { ApiOperation, ClientOptions } from '../types'; import { escapeReservedWords } from '../utils'; @@ -62,8 +69,8 @@ export function prepareOperations( const ops = fixDuplicateOperations(operations); return ops.map((op) => { - const responseObject = getBestResponse(op); - const returnType = getParameterType(responseObject, options); + const [respObject, responseContentType] = getBestResponse(op); + const returnType = getParameterType(respObject, options); const body = getRequestBody(op.requestBody); const queryParams = getParams(op.parameters as OA3.ParameterObject[], options, ['query']); @@ -80,6 +87,7 @@ export function prepareOperations( return { returnType, + responseContentType, method: op.method.toUpperCase(), name: getOperationName(op.operationId, op.group), url: op.path, @@ -186,44 +194,6 @@ function getRequestBody(reqBody: OA3.ReferenceObject | OA3.RequestBodyObject): I return null; } -const orderedContentTypes = [ - 'application/json', - 'text/json', - 'text/plain', - 'application/x-www-form-urlencoded', - 'multipart/form-data', -]; -function getBestContentType(reqBody: OA3.RequestBodyObject): [OA3.MediaTypeObject, MyContentType] { - const contentTypes = Object.keys(reqBody.content); - if (contentTypes.length === 0) { - return [null, null]; - } - - const firstContentType = orderedContentTypes.find((ct) => contentTypes.includes(ct)); - if (firstContentType) { - const typeObject = reqBody.content[firstContentType]; - const type = getContentType(firstContentType); - return [typeObject, type]; - } - - const typeObject = reqBody.content[contentTypes[0]]; - const type = getContentType(contentTypes[0]); - return [typeObject, type]; -} - -function getContentType(type: string) { - if (type === 'application/x-www-form-urlencoded') { - return 'urlencoded'; - } - if (type === 'multipart/form-data') { - return 'form-data'; - } - if (type === 'application/octet-stream') { - return 'binary'; - } - return 'json'; -} - interface ClientData { clientName: string; camelCaseName: string; @@ -233,6 +203,7 @@ interface ClientData { interface IOperation { returnType: string; + responseContentType: string; method: string; name: string; url: string; @@ -254,4 +225,3 @@ interface IOperationParam { interface IBodyParam extends IOperationParam { contentType?: MyContentType; } -type MyContentType = 'json' | 'urlencoded' | 'form-data' | 'binary'; diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index 86f4987..a7c35d3 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -250,7 +250,7 @@ describe('getBestResponse', () => { responses: {}, }; - const res = getBestResponse(op); + const [res] = getBestResponse(op); expect(res).to.be.equal(null); }); @@ -271,7 +271,7 @@ describe('getBestResponse', () => { }, }; - const res = getBestResponse(op); + const [res] = getBestResponse(op); expect(res).to.be.eql({ schema: { @@ -280,25 +280,36 @@ describe('getBestResponse', () => { }); }); - it('handles 201 response with unsupported media type', () => { - const op: OA3.OperationObject = { - responses: { - '201': { - description: 'Success', - content: { - 'application/octet-stream': { - schema: { - $ref: '#/components/schemas/TestObject', + describe('different response content types', () => { + const sampleSchema = { $ref: '#/components/schemas/TestObject' }; + const testCases = [ + { contentType: 'application/json', schema: sampleSchema, expected: 'json' }, + { contentType: 'text/json', schema: sampleSchema, expected: 'json' }, + { contentType: 'application/octet-stream', schema: sampleSchema, expected: 'binary' }, + { contentType: 'text/plain', schema: sampleSchema, expected: 'text' }, + { contentType: 'something/wrong', schema: sampleSchema, expected: 'json' }, + ]; + + for (const { contentType, schema, expected } of testCases) { + it(`handles 201 ${contentType} response`, () => { + const op: OA3.OperationObject = { + responses: { + '201': { + description: 'Success', + content: { + [contentType]: { + schema, + }, }, }, }, - }, - }, - }; + }; - const res = getBestResponse(op); + const [, respContentType] = getBestResponse(op); - expect(res).to.be.eql(null); + expect(respContentType).to.deep.equal(expected); + }); + } }); it('handles multiple responses', () => { @@ -327,7 +338,7 @@ describe('getBestResponse', () => { }, }; - const res = getBestResponse(op); + const [res] = getBestResponse(op); expect(res).to.be.eql({ schema: { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index c8b874a..03214c8 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -109,21 +109,16 @@ export function groupOperationsByGroupName(operations: ApiOperation[]) { * Other media types are not supported at this time. * @returns Response or reference of the success response */ -export function getBestResponse(op: OA3.OperationObject) { +export function getBestResponse(op: OA3.OperationObject): [OA3.MediaTypeObject, MyContentType] { const NOT_FOUND = 100000; const lowestCode = Object.keys(op.responses).sort().shift() ?? NOT_FOUND; const resp = lowestCode === NOT_FOUND ? op.responses[0] : op.responses[lowestCode.toString()]; if (resp && 'content' in resp) { - return ( - resp.content['application/json'] ?? - resp.content['text/json'] ?? - resp.content['text/plain'] ?? - null - ); + return getBestContentType(resp); } - return null; + return [null, null]; } /** This method tries to fix potentially wrong out parameter given from commandline */ @@ -150,3 +145,48 @@ export function orderBy(arr: T[] | null | undefined, key: string) { } const sortByKey = (key: string) => (a, b) => a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0; + +const orderedContentTypes = [ + 'application/json', + 'text/json', + 'text/plain', + 'application/x-www-form-urlencoded', + 'multipart/form-data', +]; +export function getBestContentType( + reqBody: OA3.RequestBodyObject | OA3.ResponseObject +): [OA3.MediaTypeObject, MyContentType] { + const contentTypes = Object.keys(reqBody.content); + if (contentTypes.length === 0) { + return [null, null]; + } + + const firstContentType = orderedContentTypes.find((ct) => contentTypes.includes(ct)); + if (firstContentType) { + const typeObject = reqBody.content[firstContentType]; + const type = getContentType(firstContentType); + return [typeObject, type]; + } + + const typeObject = reqBody.content[contentTypes[0]]; + const type = getContentType(contentTypes[0]); + return [typeObject, type]; +} + +function getContentType(type: string) { + if (type === 'application/x-www-form-urlencoded') { + return 'urlencoded'; + } + if (type === 'multipart/form-data') { + return 'form-data'; + } + if (type === 'application/octet-stream') { + return 'binary'; + } + if (type === 'text/plain') { + return 'text'; + } + return 'json'; +} + +export type MyContentType = 'json' | 'urlencoded' | 'form-data' | 'binary' | 'text'; diff --git a/templates/axios/operation.ejs b/templates/axios/operation.ejs index 0e7100b..e35d4f8 100644 --- a/templates/axios/operation.ejs +++ b/templates/axios/operation.ejs @@ -21,7 +21,7 @@ $config?: AxiosRequestConfig method: '<%= it.method %>', <% if(it.body) { %> <% if(it.body.contentType === 'urlencoded') { %> - data: new URLSearchParams(<%= it.body.name %>), + data: new URLSearchParams(<%= it.body.name %> as any), <% } else { %> data: <%= it.body.name %>, <% } %> diff --git a/templates/fetch/operation.ejs b/templates/fetch/operation.ejs index 0365044..9226d14 100644 --- a/templates/fetch/operation.ejs +++ b/templates/fetch/operation.ejs @@ -9,10 +9,10 @@ <% }); %> $config?: RequestInit ): Promise<<%~ it.returnType %>> { - let url = defaults.baseUrl + '<%= it.url %>?'; + let url = `${defaults.baseUrl}<%= it.url %>?`; <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(<%= parameter.name %>)); <% }); } %> <% if(it.query && it.query.length > 0) { %> @@ -33,7 +33,7 @@ $config?: RequestInit <% if(it.body.contentType === 'binary') { %> body: <%= it.body.name %>, <% } else if(it.body.contentType === 'urlencoded') { %> - body: new URLSearchParams(<%= it.body.name %>), + body: new URLSearchParams(<%= it.body.name %> as any), <% } else { %> body: JSON.stringify(<%= it.body.name %>), <% } %> @@ -46,5 +46,12 @@ $config?: RequestInit }, <% } %> ...$config, - }).then((response) => response.json() as Promise<<%~ it.returnType %>>); + }) +<% if(it.responseContentType === 'binary') { %> + .then((response) => response.blob() as Promise<<%~ it.returnType %>>); +<% } else if(it.responseContentType === 'text') { %> + .then((response) => response.text() as Promise<<%~ it.returnType %>>); +<% } else { %> + .then((response) => response.json() as Promise<<%~ it.returnType %>>); +<% } %> }, diff --git a/templates/ng1/operation.ejs b/templates/ng1/operation.ejs index d9ab32d..a6b549f 100644 --- a/templates/ng1/operation.ejs +++ b/templates/ng1/operation.ejs @@ -32,7 +32,7 @@ url, <% if(['POST', 'PUT', 'PATCH'].includes(it.method)) { %> <% if(it.body) { %> - <%= it.body.contentType === 'urlencoded' ? 'new URLSearchParams(' + it.body.name + ')' : it.body.name %>, + <%= it.body.contentType === 'urlencoded' ? 'new URLSearchParams(' + it.body.name + ' as any)' : it.body.name %>, <% } else { %> null, <% } %> diff --git a/templates/ng2/operation.ejs b/templates/ng2/operation.ejs index c650a08..864afcb 100644 --- a/templates/ng2/operation.ejs +++ b/templates/ng2/operation.ejs @@ -33,7 +33,7 @@ config?: any url, <% if(['POST', 'PUT', 'PATCH'].includes(it.method)) { %> <% if(it.body) { %> - <%= it.body.contentType === 'urlencoded' ? 'new URLSearchParams(' + it.body.name + ')' : it.body.name %>, + <%= it.body.contentType === 'urlencoded' ? 'new URLSearchParams(' + it.body.name + ' as any)' : it.body.name %>, <% } else { %> null, <% } %> diff --git a/templates/swr-axios/operation.ejs b/templates/swr-axios/operation.ejs index de1f07f..8760f29 100644 --- a/templates/swr-axios/operation.ejs +++ b/templates/swr-axios/operation.ejs @@ -22,7 +22,7 @@ method: '<%= it.method %>', <% if(it.body) { %> <% if(it.body.contentType === 'urlencoded') { %> - data: new URLSearchParams(<%= it.body.name %>), + data: new URLSearchParams(<%= it.body.name %> as any), <% } else { %> data: <%= it.body.name %>, <% } %> diff --git a/templates/xior/operation.ejs b/templates/xior/operation.ejs index 1045244..a6ffce0 100644 --- a/templates/xior/operation.ejs +++ b/templates/xior/operation.ejs @@ -21,7 +21,7 @@ $config?: XiorRequestConfig method: '<%= it.method %>', <% if(it.body) { %> <% if(it.body.contentType === 'urlencoded') { %> - data: new URLSearchParams(<%= it.body.name %>), + data: new URLSearchParams(<%= it.body.name %> as any), <% } else { %> data: <%= it.body.name %>, <% } %> diff --git a/test/petstore-v3.yml b/test/petstore-v3.yml index bcd4c29..4840d0e 100644 --- a/test/petstore-v3.yml +++ b/test/petstore-v3.yml @@ -298,9 +298,10 @@ paths: '200': description: successful operation content: - application/json: + application/octet-stream: schema: - $ref: '#/components/schemas/ApiResponse' + type: string + format: binary security: - petstore_auth: - 'write:pets' diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index 3cc1ce0..b84235e 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -116,7 +116,7 @@ export const petClient = { return axios.request({ url: url, method: 'PUT', - data: new URLSearchParams(body), + data: new URLSearchParams(body as any), ...$config, }); }, @@ -154,11 +154,11 @@ export const petClient = { petId: number , additionalMetadata: string | null | undefined, $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/pet/{petId}/uploadImage'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index 9708d44..503034a 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -20,13 +20,14 @@ export const petClient = { addPet(body: Pet , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet?'; + let url = `${defaults.baseUrl}/pet?`; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -37,8 +38,8 @@ export const petClient = { petId: number , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + let url = `${defaults.baseUrl}/pet/{petId}?`; + url = url.replace('{petId}', encodeURIComponent(petId)); return fetch(url, { method: 'DELETE', @@ -46,7 +47,8 @@ export const petClient = { 'api_key': apiKey, }, ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -55,7 +57,7 @@ export const petClient = { findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet/findByStatus?'; + let url = `${defaults.baseUrl}/pet/findByStatus?`; if (status !== undefined) { url += 'status=' + serializeQueryParam(status) + "&"; } @@ -63,7 +65,8 @@ export const petClient = { return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -72,7 +75,7 @@ export const petClient = { findPetsByTags(tags: string[] | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet/findByTags?'; + let url = `${defaults.baseUrl}/pet/findByTags?`; if (tags !== undefined) { url += 'tags=' + serializeQueryParam(tags) + "&"; } @@ -80,7 +83,8 @@ export const petClient = { return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -89,13 +93,14 @@ export const petClient = { getPetById(petId: number , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + let url = `${defaults.baseUrl}/pet/{petId}?`; + url = url.replace('{petId}', encodeURIComponent(petId)); return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -104,13 +109,14 @@ export const petClient = { updatePet(body: Pet , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet?'; + let url = `${defaults.baseUrl}/pet?`; return fetch(url, { method: 'PUT', - body: new URLSearchParams(body), + body: new URLSearchParams(body as any), ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -123,8 +129,8 @@ export const petClient = { status: string | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + let url = `${defaults.baseUrl}/pet/{petId}?`; + url = url.replace('{petId}', encodeURIComponent(petId)); if (name !== undefined) { url += 'name=' + serializeQueryParam(name) + "&"; } @@ -135,7 +141,8 @@ export const petClient = { return fetch(url, { method: 'POST', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -147,9 +154,9 @@ export const petClient = { petId: number , additionalMetadata: string | null | undefined, $config?: RequestInit - ): Promise { - let url = defaults.baseUrl + '/pet/{petId}/uploadImage?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + ): Promise { + let url = `${defaults.baseUrl}/pet/{petId}/uploadImage?`; + url = url.replace('{petId}', encodeURIComponent(petId)); if (additionalMetadata !== undefined) { url += 'additionalMetadata=' + serializeQueryParam(additionalMetadata) + "&"; } @@ -158,7 +165,8 @@ export const petClient = { method: 'POST', body: body, ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.blob() as Promise); }, }; @@ -169,25 +177,27 @@ export const storeClient = { deleteOrder(orderId: number , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + let url = `${defaults.baseUrl}/store/order/{orderId}?`; + url = url.replace('{orderId}', encodeURIComponent(orderId)); return fetch(url, { method: 'DELETE', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** */ getInventory($config?: RequestInit ): Promise<{ [key: string]: number }> { - let url = defaults.baseUrl + '/store/inventory?'; + let url = `${defaults.baseUrl}/store/inventory?`; return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise<{ [key: string]: number }>); + }) + .then((response) => response.json() as Promise<{ [key: string]: number }>); }, /** @@ -196,13 +206,14 @@ export const storeClient = { getOrderById(orderId: number , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + let url = `${defaults.baseUrl}/store/order/{orderId}?`; + url = url.replace('{orderId}', encodeURIComponent(orderId)); return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -211,13 +222,14 @@ export const storeClient = { placeOrder(body: Order | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/store/order?'; + let url = `${defaults.baseUrl}/store/order?`; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, }; @@ -228,13 +240,14 @@ export const userClient = { createUser(body: User | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user?'; + let url = `${defaults.baseUrl}/user?`; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -243,13 +256,14 @@ export const userClient = { createUsersWithListInput(body: User[] | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user/createWithList?'; + let url = `${defaults.baseUrl}/user/createWithList?`; return fetch(url, { method: 'POST', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -258,13 +272,14 @@ export const userClient = { deleteUser(username: string , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + let url = `${defaults.baseUrl}/user/{username}?`; + url = url.replace('{username}', encodeURIComponent(username)); return fetch(url, { method: 'DELETE', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -273,13 +288,14 @@ export const userClient = { getUserByName(username: string , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + let url = `${defaults.baseUrl}/user/{username}?`; + url = url.replace('{username}', encodeURIComponent(username)); return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -290,7 +306,7 @@ export const userClient = { password: string | null | undefined, $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user/login?'; + let url = `${defaults.baseUrl}/user/login?`; if (username !== undefined) { url += 'username=' + serializeQueryParam(username) + "&"; } @@ -301,19 +317,21 @@ export const userClient = { return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** */ logoutUser($config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user/logout?'; + let url = `${defaults.baseUrl}/user/logout?`; return fetch(url, { method: 'GET', ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, /** @@ -324,14 +342,15 @@ export const userClient = { username: string , $config?: RequestInit ): Promise { - let url = defaults.baseUrl + '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + let url = `${defaults.baseUrl}/user/{username}?`; + url = url.replace('{username}', encodeURIComponent(username)); return fetch(url, { method: 'PUT', body: JSON.stringify(body), ...$config, - }).then((response) => response.json() as Promise); + }) + .then((response) => response.json() as Promise); }, }; diff --git a/test/snapshots/ng1.ts b/test/snapshots/ng1.ts index 5652728..da096de 100644 --- a/test/snapshots/ng1.ts +++ b/test/snapshots/ng1.ts @@ -225,7 +225,7 @@ export class petService extends BaseService { return this.$put( url, - new URLSearchParams(body), + new URLSearchParams(body as any), config ); } @@ -267,7 +267,7 @@ export class petService extends BaseService { petId: number , additionalMetadata: string | null | undefined, config?: IRequestShortcutConfig - ): IPromise { + ): IPromise { let url = '/pet/{petId}/uploadImage?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); if (additionalMetadata !== undefined) { diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index 96c3af8..1a632c8 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -178,7 +178,7 @@ export class petService extends BaseService { return this.$put( url, - new URLSearchParams(body), + new URLSearchParams(body as any), config ); } @@ -222,7 +222,7 @@ export class petService extends BaseService { petId: number, additionalMetadata: string | null | undefined, config?: any - ): Observable { + ): Observable { let url = '/pet/{petId}/uploadImage?'; url = url.replace('{petId}', encodeURIComponent("" + petId)); if (additionalMetadata !== undefined) { diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 0070964..0e9e84d 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -130,7 +130,7 @@ export const petClient = { return axios.request({ url: url, method: 'PUT', - data: new URLSearchParams(body), + data: new URLSearchParams(body as any), ...$config, }); }, @@ -169,12 +169,12 @@ export const petClient = { petId: number , additionalMetadata: string | null | undefined, $config?: AxiosRequestConfig - ): AxiosPromise { + ): AxiosPromise { let url = '/pet/{petId}/uploadImage'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - return axios.request({ + return axios.request({ url: url, method: 'POST', data: body, diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 68873c5..4701beb 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -116,7 +116,7 @@ export const petClient = { return http.request({ url: url, method: 'PUT', - data: new URLSearchParams(body), + data: new URLSearchParams(body as any), ...$config, }); }, @@ -154,11 +154,11 @@ export const petClient = { petId: number , additionalMetadata: string | null | undefined, $config?: XiorRequestConfig - ): Promise> { + ): Promise> { let url = '/pet/{petId}/uploadImage'; url = url.replace('{petId}', encodeURIComponent("" + petId)); - return http.request({ + return http.request({ url: url, method: 'POST', data: body, From ac5221f65e9bbe9083b6be3076430c3ecf7448c9 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Sun, 7 Jul 2024 11:16:30 +0200 Subject: [PATCH 15/27] impr: better code in templates with less warnings --- templates/axios/operation.ejs | 2 +- templates/fetch/barrel.ejs | 2 +- templates/fetch/operation.ejs | 8 ++--- templates/ng1/operation.ejs | 2 +- templates/ng2/baseClient.ejs | 4 +-- templates/ng2/operation.ejs | 10 +++--- templates/swr-axios/operation.ejs | 2 +- templates/swr-axios/swrOperation.ejs | 2 +- templates/xior/operation.ejs | 2 +- test/snapshots/axios.ts | 18 +++++----- test/snapshots/fetch.ts | 36 ++++++++++---------- test/snapshots/ng1.ts | 18 +++++----- test/snapshots/ng2.ts | 50 ++++++++++++++-------------- test/snapshots/swr-axios.ts | 24 ++++++------- test/snapshots/xior.ts | 18 +++++----- 15 files changed, 99 insertions(+), 99 deletions(-) diff --git a/templates/axios/operation.ejs b/templates/axios/operation.ejs index e35d4f8..ee5d275 100644 --- a/templates/axios/operation.ejs +++ b/templates/axios/operation.ejs @@ -12,7 +12,7 @@ $config?: AxiosRequestConfig let url = '<%= it.url %>'; <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> diff --git a/templates/fetch/barrel.ejs b/templates/fetch/barrel.ejs index b34268a..c4a6c8f 100644 --- a/templates/fetch/barrel.ejs +++ b/templates/fetch/barrel.ejs @@ -4,7 +4,7 @@ function serializeQueryParam(obj: any) { if (obj instanceof Date) return encodeURIComponent(obj.toJSON()); if (typeof obj !== 'object' || Array.isArray(obj)) return encodeURIComponent(obj); return Object.keys(obj) - .reduce((a: any, b) => a.push(encodeURIComponent(b) + '=' + encodeURIComponent(obj[b])) && a, []) + .reduce((a: any, b) => a.push(`${encodeURIComponent(b)}=${encodeURIComponent(obj[b])}`) && a, []) .join('&'); } diff --git a/templates/fetch/operation.ejs b/templates/fetch/operation.ejs index 9226d14..3190208 100644 --- a/templates/fetch/operation.ejs +++ b/templates/fetch/operation.ejs @@ -12,16 +12,16 @@ $config?: RequestInit let url = `${defaults.baseUrl}<%= it.url %>?`; <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(<%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> if (<%= parameter.name %> !== undefined) { <% if(!!parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach(item => { url += '<%= parameter.originalName %>=' + serializeQueryParam(item) + "&"; }); + <%= parameter.name %>.forEach(item => { url += `<%= parameter.originalName %>=${serializeQueryParam(item)}&`; }); <% } else {%> - url += '<%= parameter.originalName %>=' + serializeQueryParam(<%= parameter.name %>) + "&"; + url += `<%= parameter.originalName %>=${serializeQueryParam(<%= parameter.name %>)}&`; <% } %> } <% }); %> @@ -41,7 +41,7 @@ $config?: RequestInit <% if(it.headers && it.headers.length > 0) { %> headers: { <% it.headers.forEach((parameter) => { %> - '<%= parameter.originalName %>': <%= parameter.name %>, + '<%= parameter.originalName %>': <%= parameter.name %> ?? '', <% }); %> }, <% } %> diff --git a/templates/ng1/operation.ejs b/templates/ng1/operation.ejs index a6b549f..9d651b2 100644 --- a/templates/ng1/operation.ejs +++ b/templates/ng1/operation.ejs @@ -13,7 +13,7 @@ let url = '<%= it.url %>?'; <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> <% if(it.query && it.query.length > 0) { %> diff --git a/templates/ng2/baseClient.ejs b/templates/ng2/baseClient.ejs index 3c28bad..8acd277 100644 --- a/templates/ng2/baseClient.ejs +++ b/templates/ng2/baseClient.ejs @@ -9,9 +9,9 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import { Observable, throwError as _observableThrow, of as _observableOf } from "rxjs"; +import { Observable } from "rxjs"; import { Injectable, Inject, Optional, InjectionToken } from "@angular/core"; -import { HttpClient, HttpHeaders, HttpResponse, HttpResponseBase } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; export const <%= (it.servicePrefix || 'API').toUpperCase() -%>_BASE_URL = new InjectionToken("<%= (it.servicePrefix || 'API').toUpperCase() -%>_BASE_URL"); diff --git a/templates/ng2/operation.ejs b/templates/ng2/operation.ejs index 864afcb..cdfa883 100644 --- a/templates/ng2/operation.ejs +++ b/templates/ng2/operation.ejs @@ -14,16 +14,16 @@ config?: any let url = '<%= it.url %>?'; <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> - if (<%= parameter.name %> !== undefined) { - <% if(!!parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach(item => { url += '<%= parameter.originalName %>=' + encodeURIComponent("" + item) + "&"; }); + if (<%= parameter.name %> !== undefined && <%= parameter.name %> !== null) { + <% if(parameter.original?.type === 'array') { %> + <%= parameter.name %>.forEach(item => { url += `<%= parameter.originalName %>=${encodeURIComponent(`${item}`)}&`; }); <% } else {%> - url += '<%= parameter.originalName %>=' + encodeURIComponent("" + <%= parameter.name %>) + "&"; + url += `<%= parameter.originalName %>=${encodeURIComponent(`${<%= parameter.name %>}`)}&`; <% } %> } <% }); %> diff --git a/templates/swr-axios/operation.ejs b/templates/swr-axios/operation.ejs index 8760f29..20cf401 100644 --- a/templates/swr-axios/operation.ejs +++ b/templates/swr-axios/operation.ejs @@ -13,7 +13,7 @@ <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> diff --git a/templates/swr-axios/swrOperation.ejs b/templates/swr-axios/swrOperation.ejs index 2e071a7..32becf8 100644 --- a/templates/swr-axios/swrOperation.ejs +++ b/templates/swr-axios/swrOperation.ejs @@ -14,7 +14,7 @@ export function <%= it.swrOpName %>(<% it.parameters.forEach((parameter) => { %> <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> diff --git a/templates/xior/operation.ejs b/templates/xior/operation.ejs index a6ffce0..ce09fc0 100644 --- a/templates/xior/operation.ejs +++ b/templates/xior/operation.ejs @@ -12,7 +12,7 @@ $config?: XiorRequestConfig let url = '<%= it.url %>'; <% if(it.pathParams && it.pathParams.length > 0) { it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent("" + <%= parameter.name %>)); + url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); <% }); } %> diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index b84235e..2403dff 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -41,7 +41,7 @@ export const petClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -96,7 +96,7 @@ export const petClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -132,7 +132,7 @@ export const petClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -156,7 +156,7 @@ export const petClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/pet/{petId}/uploadImage'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -179,7 +179,7 @@ export const storeClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return axios.request({ url: url, @@ -208,7 +208,7 @@ export const storeClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return axios.request({ url: url, @@ -275,7 +275,7 @@ export const userClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return axios.request({ url: url, @@ -291,7 +291,7 @@ export const userClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return axios.request({ url: url, @@ -343,7 +343,7 @@ export const userClient = { $config?: AxiosRequestConfig ): AxiosPromise { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return axios.request({ url: url, diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index 503034a..f76b45f 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -39,12 +39,12 @@ export const petClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/pet/{petId}?`; - url = url.replace('{petId}', encodeURIComponent(petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return fetch(url, { method: 'DELETE', headers: { - 'api_key': apiKey, + 'api_key': apiKey ?? '', }, ...$config, }) @@ -59,7 +59,7 @@ export const petClient = { ): Promise { let url = `${defaults.baseUrl}/pet/findByStatus?`; if (status !== undefined) { - url += 'status=' + serializeQueryParam(status) + "&"; + url += `status=${serializeQueryParam(status)}&`; } return fetch(url, { @@ -77,7 +77,7 @@ export const petClient = { ): Promise { let url = `${defaults.baseUrl}/pet/findByTags?`; if (tags !== undefined) { - url += 'tags=' + serializeQueryParam(tags) + "&"; + url += `tags=${serializeQueryParam(tags)}&`; } return fetch(url, { @@ -94,7 +94,7 @@ export const petClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/pet/{petId}?`; - url = url.replace('{petId}', encodeURIComponent(petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return fetch(url, { method: 'GET', @@ -130,12 +130,12 @@ export const petClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/pet/{petId}?`; - url = url.replace('{petId}', encodeURIComponent(petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); if (name !== undefined) { - url += 'name=' + serializeQueryParam(name) + "&"; + url += `name=${serializeQueryParam(name)}&`; } if (status !== undefined) { - url += 'status=' + serializeQueryParam(status) + "&"; + url += `status=${serializeQueryParam(status)}&`; } return fetch(url, { @@ -156,9 +156,9 @@ export const petClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/pet/{petId}/uploadImage?`; - url = url.replace('{petId}', encodeURIComponent(petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); if (additionalMetadata !== undefined) { - url += 'additionalMetadata=' + serializeQueryParam(additionalMetadata) + "&"; + url += `additionalMetadata=${serializeQueryParam(additionalMetadata)}&`; } return fetch(url, { @@ -178,7 +178,7 @@ export const storeClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/store/order/{orderId}?`; - url = url.replace('{orderId}', encodeURIComponent(orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return fetch(url, { method: 'DELETE', @@ -207,7 +207,7 @@ export const storeClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/store/order/{orderId}?`; - url = url.replace('{orderId}', encodeURIComponent(orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return fetch(url, { method: 'GET', @@ -273,7 +273,7 @@ export const userClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/user/{username}?`; - url = url.replace('{username}', encodeURIComponent(username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return fetch(url, { method: 'DELETE', @@ -289,7 +289,7 @@ export const userClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/user/{username}?`; - url = url.replace('{username}', encodeURIComponent(username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return fetch(url, { method: 'GET', @@ -308,10 +308,10 @@ export const userClient = { ): Promise { let url = `${defaults.baseUrl}/user/login?`; if (username !== undefined) { - url += 'username=' + serializeQueryParam(username) + "&"; + url += `username=${serializeQueryParam(username)}&`; } if (password !== undefined) { - url += 'password=' + serializeQueryParam(password) + "&"; + url += `password=${serializeQueryParam(password)}&`; } return fetch(url, { @@ -343,7 +343,7 @@ export const userClient = { $config?: RequestInit ): Promise { let url = `${defaults.baseUrl}/user/{username}?`; - url = url.replace('{username}', encodeURIComponent(username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return fetch(url, { method: 'PUT', @@ -360,7 +360,7 @@ function serializeQueryParam(obj: any) { if (obj instanceof Date) return encodeURIComponent(obj.toJSON()); if (typeof obj !== 'object' || Array.isArray(obj)) return encodeURIComponent(obj); return Object.keys(obj) - .reduce((a: any, b) => a.push(encodeURIComponent(b) + '=' + encodeURIComponent(obj[b])) && a, []) + .reduce((a: any, b) => a.push(`${encodeURIComponent(b)}=${encodeURIComponent(obj[b])}`) && a, []) .join('&'); } diff --git a/test/snapshots/ng1.ts b/test/snapshots/ng1.ts index da096de..0c590ff 100644 --- a/test/snapshots/ng1.ts +++ b/test/snapshots/ng1.ts @@ -154,7 +154,7 @@ export class petService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return this.$delete( url, @@ -206,7 +206,7 @@ export class petService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return this.$get( url, @@ -242,7 +242,7 @@ export class petService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); if (name !== undefined) { url += serializeQueryParam(name, 'name') + "&"; } @@ -269,7 +269,7 @@ export class petService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/pet/{petId}/uploadImage?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); if (additionalMetadata !== undefined) { url += serializeQueryParam(additionalMetadata, 'additionalMetadata') + "&"; } @@ -297,7 +297,7 @@ export class storeService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return this.$delete( url, @@ -326,7 +326,7 @@ export class storeService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return this.$get( url, @@ -398,7 +398,7 @@ export class userService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return this.$delete( url, @@ -414,7 +414,7 @@ export class userService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return this.$get( url, @@ -468,7 +468,7 @@ export class userService extends BaseService { config?: IRequestShortcutConfig ): IPromise { let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return this.$put( url, diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index 1a632c8..2c5a941 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -9,9 +9,9 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import { Observable, throwError as _observableThrow, of as _observableOf } from "rxjs"; +import { Observable } from "rxjs"; import { Injectable, Inject, Optional, InjectionToken } from "@angular/core"; -import { HttpClient, HttpHeaders, HttpResponse, HttpResponseBase } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; export const API_BASE_URL = new InjectionToken("API_BASE_URL"); @@ -103,7 +103,7 @@ export class petService extends BaseService { config?: any ): Observable { let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return this.$delete( url, @@ -120,8 +120,8 @@ export class petService extends BaseService { config?: any ): Observable { let url = '/pet/findByStatus?'; - if (status !== undefined) { - url += 'status=' + encodeURIComponent("" + status) + "&"; + if (status !== undefined && status !== null) { + url += `status=${encodeURIComponent(`${status}`)}&`; } return this.$get( @@ -139,8 +139,8 @@ export class petService extends BaseService { config?: any ): Observable { let url = '/pet/findByTags?'; - if (tags !== undefined) { - url += 'tags=' + encodeURIComponent("" + tags) + "&"; + if (tags !== undefined && tags !== null) { + url += `tags=${encodeURIComponent(`${tags}`)}&`; } return this.$get( @@ -158,7 +158,7 @@ export class petService extends BaseService { config?: any ): Observable { let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return this.$get( url, @@ -196,12 +196,12 @@ export class petService extends BaseService { config?: any ): Observable { let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); - if (name !== undefined) { - url += 'name=' + encodeURIComponent("" + name) + "&"; + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + if (name !== undefined && name !== null) { + url += `name=${encodeURIComponent(`${name}`)}&`; } - if (status !== undefined) { - url += 'status=' + encodeURIComponent("" + status) + "&"; + if (status !== undefined && status !== null) { + url += `status=${encodeURIComponent(`${status}`)}&`; } return this.$post( @@ -224,9 +224,9 @@ export class petService extends BaseService { config?: any ): Observable { let url = '/pet/{petId}/uploadImage?'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); - if (additionalMetadata !== undefined) { - url += 'additionalMetadata=' + encodeURIComponent("" + additionalMetadata) + "&"; + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + if (additionalMetadata !== undefined && additionalMetadata !== null) { + url += `additionalMetadata=${encodeURIComponent(`${additionalMetadata}`)}&`; } return this.$post( @@ -258,7 +258,7 @@ export class storeService extends BaseService { config?: any ): Observable { let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return this.$delete( url, @@ -289,7 +289,7 @@ export class storeService extends BaseService { config?: any ): Observable { let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return this.$get( url, @@ -370,7 +370,7 @@ export class userService extends BaseService { config?: any ): Observable { let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return this.$delete( url, @@ -387,7 +387,7 @@ export class userService extends BaseService { config?: any ): Observable { let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return this.$get( url, @@ -406,11 +406,11 @@ export class userService extends BaseService { config?: any ): Observable { let url = '/user/login?'; - if (username !== undefined) { - url += 'username=' + encodeURIComponent("" + username) + "&"; + if (username !== undefined && username !== null) { + url += `username=${encodeURIComponent(`${username}`)}&`; } - if (password !== undefined) { - url += 'password=' + encodeURIComponent("" + password) + "&"; + if (password !== undefined && password !== null) { + url += `password=${encodeURIComponent(`${password}`)}&`; } return this.$get( @@ -444,7 +444,7 @@ export class userService extends BaseService { config?: any ): Observable { let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return this.$put( url, diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 0e9e84d..643ed69 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -51,7 +51,7 @@ export const petClient = { ): AxiosPromise { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -109,7 +109,7 @@ export const petClient = { ): AxiosPromise { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -147,7 +147,7 @@ export const petClient = { ): AxiosPromise { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -172,7 +172,7 @@ export const petClient = { ): AxiosPromise { let url = '/pet/{petId}/uploadImage'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return axios.request({ url: url, @@ -266,7 +266,7 @@ export function usepetPetById( petId: number , let url = '/pet/{petId}'; const { axios: $axiosConf, key, ...config } = $config || {}; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( @@ -295,7 +295,7 @@ const { data, error, mutate } = useSWR( ): AxiosPromise { let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return axios.request({ url: url, @@ -326,7 +326,7 @@ const { data, error, mutate } = useSWR( ): AxiosPromise { let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return axios.request({ url: url, @@ -389,7 +389,7 @@ export function usestoreOrderById( orderId: number , let url = '/store/order/{orderId}'; const { axios: $axiosConf, key, ...config } = $config || {}; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( @@ -452,7 +452,7 @@ const { data, error, mutate } = useSWR( ): AxiosPromise { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return axios.request({ url: url, @@ -469,7 +469,7 @@ const { data, error, mutate } = useSWR( ): AxiosPromise { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return axios.request({ url: url, @@ -524,7 +524,7 @@ const { data, error, mutate } = useSWR( ): AxiosPromise { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return axios.request({ url: url, @@ -545,7 +545,7 @@ export function useuserUserByName( username: string , let url = '/user/{username}'; const { axios: $axiosConf, key, ...config } = $config || {}; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 4701beb..0c7608a 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -41,7 +41,7 @@ export const petClient = { $config?: XiorRequestConfig ): Promise> { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return http.request({ url: url, @@ -96,7 +96,7 @@ export const petClient = { $config?: XiorRequestConfig ): Promise> { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return http.request({ url: url, @@ -132,7 +132,7 @@ export const petClient = { $config?: XiorRequestConfig ): Promise> { let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return http.request({ url: url, @@ -156,7 +156,7 @@ export const petClient = { $config?: XiorRequestConfig ): Promise> { let url = '/pet/{petId}/uploadImage'; - url = url.replace('{petId}', encodeURIComponent("" + petId)); + url = url.replace('{petId}', encodeURIComponent(`${petId}`)); return http.request({ url: url, @@ -179,7 +179,7 @@ export const storeClient = { $config?: XiorRequestConfig ): Promise> { let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return http.request({ url: url, @@ -208,7 +208,7 @@ export const storeClient = { $config?: XiorRequestConfig ): Promise> { let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent("" + orderId)); + url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); return http.request({ url: url, @@ -275,7 +275,7 @@ export const userClient = { $config?: XiorRequestConfig ): Promise> { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return http.request({ url: url, @@ -291,7 +291,7 @@ export const userClient = { $config?: XiorRequestConfig ): Promise> { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return http.request({ url: url, @@ -343,7 +343,7 @@ export const userClient = { $config?: XiorRequestConfig ): Promise> { let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent("" + username)); + url = url.replace('{username}', encodeURIComponent(`${username}`)); return http.request({ url: url, From 7ba815ffec976604dd4b7b4a02d649b72be1dc49 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Sun, 7 Jul 2024 13:48:56 +0200 Subject: [PATCH 16/27] impr: simplify processing path params and now templates are cleaner and shorter And also mismatch between path expression and params will be much more visible --- src/gen/genOperations.spec.ts | 41 +++++++++--- src/gen/genOperations.ts | 17 ++++- src/utils/templateManager.spec.ts | 2 +- templates/axios/operation.ejs | 7 +-- templates/fetch/operation.ejs | 5 -- templates/ng1/operation.ejs | 7 +-- templates/ng2/operation.ejs | 7 +-- templates/swr-axios/operation.ejs | 8 +-- templates/swr-axios/swrOperation.ejs | 8 +-- templates/xior/barrel.ejs | 2 +- templates/xior/operation.ejs | 7 +-- test/snapshots/axios.ts | 47 ++++++-------- test/snapshots/fetch.ts | 27 +++----- test/snapshots/ng1.ts | 47 ++++++-------- test/snapshots/ng2.ts | 47 ++++++-------- test/snapshots/swr-axios.ts | 93 ++++++++-------------------- test/snapshots/xior.ts | 49 ++++++--------- 17 files changed, 169 insertions(+), 252 deletions(-) diff --git a/src/gen/genOperations.spec.ts b/src/gen/genOperations.spec.ts index 3ab2074..9100879 100644 --- a/src/gen/genOperations.spec.ts +++ b/src/gen/genOperations.spec.ts @@ -69,13 +69,6 @@ describe('prepareOperations', () => { optional: true, }); - expect(res.pathParams.pop()).to.deep.include({ - name: 'petId', - originalName: 'petId', - type: 'number', - optional: true, - }); - expect(res.parameters.length).to.equal(3); expect(res.parameters.map((p) => p.name)).to.deep.equal(['orgID', 'orgType', 'petId']); }); @@ -105,6 +98,40 @@ describe('prepareOperations', () => { expect(op2.parameters).to.deep.equal([]); }); + it('should prepare URL correctly', () => { + const ops: ApiOperation[] = [ + { + operationId: 'get1', + method: 'get', + path: '/pet/{petId}', + responses: {}, + group: null, + }, + { + operationId: 'get2', + method: 'get', + path: '/users/{userId}/Wrong{/Path}', + parameters: [], + responses: {}, + group: null, + }, + { + operationId: 'get3', + method: 'get', + path: '/users/{}/Wrong{', + parameters: [], + responses: {}, + group: null, + }, + ]; + + const [op1, op2, op3] = prepareOperations(ops, opts); + + expect(op1.url).to.equal('/pet/${encodeURIComponent(`${petId}`)}'); + expect(op2.url).to.equal('/users/${encodeURIComponent(`${userId}`)}/Wrong{/Path}'); + expect(op3.url).to.equal('/users/{}/Wrong{'); + }); + describe('requestBody (JSON)', () => { it('should handle requestBody with ref type', () => { const ops: ApiOperation[] = [ diff --git a/src/gen/genOperations.ts b/src/gen/genOperations.ts index 5115797..f508596 100644 --- a/src/gen/genOperations.ts +++ b/src/gen/genOperations.ts @@ -90,16 +90,28 @@ export function prepareOperations( responseContentType, method: op.method.toUpperCase(), name: getOperationName(op.operationId, op.group), - url: op.path, + url: prepareUrl(op.path), parameters: params, query: queryParams, - pathParams: getParams(op.parameters as OA3.ParameterObject[], options, ['path']), body, headers: getParams(op.parameters as OA3.ParameterObject[], options, ['header']), }; }); } +/** + * This function will replace path template expressions with ${encodeURIComponent('paramName')} placeholders + * The end result will be a string that is effectively a template (i.e. you should wrap end result with backticks) + * This method is not really safe, but it will point out the potential issues with the path template expressions + * So that the developer can see actual problem in the compiled code (as opposed to having a runtime issues) + */ +function prepareUrl(path: string): string { + return path.replace( + /{([^}/]+)}/g, + (_, paramName) => `\${encodeURIComponent(\`\${${paramName}}\`)}` + ); +} + /** * Let's add numbers to the duplicated operation names to avoid breaking code. * Duplicated operation names are not allowed by the OpenAPI spec, but in the real world @@ -209,7 +221,6 @@ interface IOperation { url: string; parameters: IOperationParam[]; query: IOperationParam[]; - pathParams: IOperationParam[]; body: IBodyParam; headers: IOperationParam[]; } diff --git a/src/utils/templateManager.spec.ts b/src/utils/templateManager.spec.ts index cf0c5dd..ea33f75 100644 --- a/src/utils/templateManager.spec.ts +++ b/src/utils/templateManager.spec.ts @@ -53,8 +53,8 @@ describe('render', () => { parameters: [], name: 'TestName', returnType: 'string', + responseContentType: 'json', url: 'api/test', - pathParams: [], method: 'GET', formData: [], body: null, diff --git a/templates/axios/operation.ejs b/templates/axios/operation.ejs index ee5d275..37bdb32 100644 --- a/templates/axios/operation.ejs +++ b/templates/axios/operation.ejs @@ -9,12 +9,7 @@ <% }); %> $config?: AxiosRequestConfig ): AxiosPromise<<%~ it.returnType %>> { - let url = '<%= it.url %>'; -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> + const url = `<%= it.url %>`; return axios.request<<%~ it.returnType %>>({ url: url, diff --git a/templates/fetch/operation.ejs b/templates/fetch/operation.ejs index 3190208..0cecf0d 100644 --- a/templates/fetch/operation.ejs +++ b/templates/fetch/operation.ejs @@ -10,11 +10,6 @@ $config?: RequestInit ): Promise<<%~ it.returnType %>> { let url = `${defaults.baseUrl}<%= it.url %>?`; -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> if (<%= parameter.name %> !== undefined) { diff --git a/templates/ng1/operation.ejs b/templates/ng1/operation.ejs index 9d651b2..b9e779a 100644 --- a/templates/ng1/operation.ejs +++ b/templates/ng1/operation.ejs @@ -10,12 +10,7 @@ <% }); %> config?: IRequestShortcutConfig ): IPromise<<%~ it.returnType %>> { - let url = '<%= it.url %>?'; -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> + let url = `<%= it.url %>?`; <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> if (<%= parameter.name %> !== undefined) { diff --git a/templates/ng2/operation.ejs b/templates/ng2/operation.ejs index cdfa883..1f9c36d 100644 --- a/templates/ng2/operation.ejs +++ b/templates/ng2/operation.ejs @@ -11,12 +11,7 @@ <% }); %> config?: any ): Observable<<%~ it.returnType %>> { - let url = '<%= it.url %>?'; -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> + let url = `<%= it.url %>?`; <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> if (<%= parameter.name %> !== undefined && <%= parameter.name %> !== null) { diff --git a/templates/swr-axios/operation.ejs b/templates/swr-axios/operation.ejs index 20cf401..3edcb3b 100644 --- a/templates/swr-axios/operation.ejs +++ b/templates/swr-axios/operation.ejs @@ -9,13 +9,7 @@ <% }); %> $config?: AxiosRequestConfig ): AxiosPromise<<%~ it.returnType %>> { - let url = '<%= it.url %>'; - -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> + const url = `<%= it.url %>`; return axios.request<<%~ it.returnType %>>({ url: url, diff --git a/templates/swr-axios/swrOperation.ejs b/templates/swr-axios/swrOperation.ejs index 32becf8..32b83e9 100644 --- a/templates/swr-axios/swrOperation.ejs +++ b/templates/swr-axios/swrOperation.ejs @@ -9,15 +9,9 @@ export function <%= it.swrOpName %>(<% it.parameters.forEach((parameter) => { %> <% }); %> $config?: SwrConfig ) { - let url = '<%= it.url %>'; + const url = `<%= it.url %>`; const { axios: $axiosConf, key, ...config } = $config || {}; -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> - let cacheUrl = url + '?'; <% if(it.query && it.query.length > 0) { %> <% it.query.forEach((parameter) => { %> diff --git a/templates/xior/barrel.ejs b/templates/xior/barrel.ejs index c82c1b2..1f801bd 100644 --- a/templates/xior/barrel.ejs +++ b/templates/xior/barrel.ejs @@ -4,7 +4,7 @@ function serializeQueryParam(obj: any) { if (obj instanceof Date) return obj.toJSON(); if (typeof obj !== 'object' || Array.isArray(obj)) return obj; return Object.keys(obj) - .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) + .reduce((a: any, b) => a.push(`${b}=${obj[b]}`) && a, []) .join('&'); } diff --git a/templates/xior/operation.ejs b/templates/xior/operation.ejs index ce09fc0..a7a1b8c 100644 --- a/templates/xior/operation.ejs +++ b/templates/xior/operation.ejs @@ -9,12 +9,7 @@ <% }); %> $config?: XiorRequestConfig ): Promise>> { - let url = '<%= it.url %>'; -<% if(it.pathParams && it.pathParams.length > 0) { - it.pathParams.forEach((parameter) => { %> - url = url.replace('{<%= parameter.name %>}', encodeURIComponent(`${<%= parameter.name %>}`)); -<% }); -} %> + const url = `<%= it.url %>`; return http.request<<%~ it.returnType %>>({ url: url, diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index 2403dff..7af17c3 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -22,7 +22,7 @@ export const petClient = { addPet(body: Pet , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet'; + const url = `/pet`; return axios.request({ url: url, @@ -40,8 +40,7 @@ export const petClient = { petId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -59,7 +58,7 @@ export const petClient = { findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/findByStatus'; + const url = `/pet/findByStatus`; return axios.request({ url: url, @@ -77,7 +76,7 @@ export const petClient = { findPetsByTags(tags: string[] | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/findByTags'; + const url = `/pet/findByTags`; return axios.request({ url: url, @@ -95,8 +94,7 @@ export const petClient = { getPetById(petId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -111,7 +109,7 @@ export const petClient = { updatePet(body: Pet , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet'; + const url = `/pet`; return axios.request({ url: url, @@ -131,8 +129,7 @@ export const petClient = { status: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -155,8 +152,7 @@ export const petClient = { additionalMetadata: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}/uploadImage'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage`; return axios.request({ url: url, @@ -178,8 +174,7 @@ export const storeClient = { deleteOrder(orderId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; return axios.request({ url: url, @@ -192,7 +187,7 @@ export const storeClient = { */ getInventory($config?: AxiosRequestConfig ): AxiosPromise<{ [key: string]: number }> { - let url = '/store/inventory'; + const url = `/store/inventory`; return axios.request<{ [key: string]: number }>({ url: url, @@ -207,8 +202,7 @@ export const storeClient = { getOrderById(orderId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; return axios.request({ url: url, @@ -223,7 +217,7 @@ export const storeClient = { placeOrder(body: Order | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/store/order'; + const url = `/store/order`; return axios.request({ url: url, @@ -242,7 +236,7 @@ export const userClient = { createUser(body: User | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user'; + const url = `/user`; return axios.request({ url: url, @@ -258,7 +252,7 @@ export const userClient = { createUsersWithListInput(body: User[] | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/createWithList'; + const url = `/user/createWithList`; return axios.request({ url: url, @@ -274,8 +268,7 @@ export const userClient = { deleteUser(username: string , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return axios.request({ url: url, @@ -290,8 +283,7 @@ export const userClient = { getUserByName(username: string , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return axios.request({ url: url, @@ -308,7 +300,7 @@ export const userClient = { password: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/login'; + const url = `/user/login`; return axios.request({ url: url, @@ -325,7 +317,7 @@ export const userClient = { */ logoutUser($config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/logout'; + const url = `/user/logout`; return axios.request({ url: url, @@ -342,8 +334,7 @@ export const userClient = { username: string , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return axios.request({ url: url, diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index f76b45f..6d4f86e 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -38,8 +38,7 @@ export const petClient = { petId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/{petId}?`; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; return fetch(url, { method: 'DELETE', @@ -93,8 +92,7 @@ export const petClient = { getPetById(petId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/{petId}?`; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; return fetch(url, { method: 'GET', @@ -129,8 +127,7 @@ export const petClient = { status: string | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/{petId}?`; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; if (name !== undefined) { url += `name=${serializeQueryParam(name)}&`; } @@ -155,8 +152,7 @@ export const petClient = { additionalMetadata: string | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/{petId}/uploadImage?`; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}/uploadImage?`; if (additionalMetadata !== undefined) { url += `additionalMetadata=${serializeQueryParam(additionalMetadata)}&`; } @@ -177,8 +173,7 @@ export const storeClient = { deleteOrder(orderId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/store/order/{orderId}?`; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + let url = `${defaults.baseUrl}/store/order/${encodeURIComponent(`${orderId}`)}?`; return fetch(url, { method: 'DELETE', @@ -206,8 +201,7 @@ export const storeClient = { getOrderById(orderId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/store/order/{orderId}?`; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + let url = `${defaults.baseUrl}/store/order/${encodeURIComponent(`${orderId}`)}?`; return fetch(url, { method: 'GET', @@ -272,8 +266,7 @@ export const userClient = { deleteUser(username: string , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/{username}?`; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; return fetch(url, { method: 'DELETE', @@ -288,8 +281,7 @@ export const userClient = { getUserByName(username: string , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/{username}?`; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; return fetch(url, { method: 'GET', @@ -342,8 +334,7 @@ export const userClient = { username: string , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/{username}?`; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; return fetch(url, { method: 'PUT', diff --git a/test/snapshots/ng1.ts b/test/snapshots/ng1.ts index 0c590ff..fb3bc32 100644 --- a/test/snapshots/ng1.ts +++ b/test/snapshots/ng1.ts @@ -135,7 +135,7 @@ export class petService extends BaseService { addPet(body: Pet , config?: IRequestShortcutConfig ): IPromise { - let url = '/pet?'; + let url = `/pet?`; return this.$post( url, @@ -153,8 +153,7 @@ export class petService extends BaseService { petId: number , config?: IRequestShortcutConfig ): IPromise { - let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}?`; return this.$delete( url, @@ -169,7 +168,7 @@ export class petService extends BaseService { findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/pet/findByStatus?'; + let url = `/pet/findByStatus?`; if (status !== undefined) { url += serializeQueryParam(status, 'status') + "&"; } @@ -187,7 +186,7 @@ export class petService extends BaseService { findPetsByTags(tags: string[] | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/pet/findByTags?'; + let url = `/pet/findByTags?`; if (tags !== undefined) { url += serializeQueryParam(tags, 'tags') + "&"; } @@ -205,8 +204,7 @@ export class petService extends BaseService { getPetById(petId: number , config?: IRequestShortcutConfig ): IPromise { - let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}?`; return this.$get( url, @@ -221,7 +219,7 @@ export class petService extends BaseService { updatePet(body: Pet , config?: IRequestShortcutConfig ): IPromise { - let url = '/pet?'; + let url = `/pet?`; return this.$put( url, @@ -241,8 +239,7 @@ export class petService extends BaseService { status: string | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}?`; if (name !== undefined) { url += serializeQueryParam(name, 'name') + "&"; } @@ -268,8 +265,7 @@ export class petService extends BaseService { additionalMetadata: string | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/pet/{petId}/uploadImage?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage?`; if (additionalMetadata !== undefined) { url += serializeQueryParam(additionalMetadata, 'additionalMetadata') + "&"; } @@ -296,8 +292,7 @@ export class storeService extends BaseService { deleteOrder(orderId: number , config?: IRequestShortcutConfig ): IPromise { - let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + let url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; return this.$delete( url, @@ -310,7 +305,7 @@ export class storeService extends BaseService { */ getInventory( config?: IRequestShortcutConfig ): IPromise<{ [key: string]: number }> { - let url = '/store/inventory?'; + let url = `/store/inventory?`; return this.$get( url, @@ -325,8 +320,7 @@ export class storeService extends BaseService { getOrderById(orderId: number , config?: IRequestShortcutConfig ): IPromise { - let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + let url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; return this.$get( url, @@ -341,7 +335,7 @@ export class storeService extends BaseService { placeOrder(body: Order | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/store/order?'; + let url = `/store/order?`; return this.$post( url, @@ -365,7 +359,7 @@ export class userService extends BaseService { createUser(body: User | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/user?'; + let url = `/user?`; return this.$post( url, @@ -381,7 +375,7 @@ export class userService extends BaseService { createUsersWithListInput(body: User[] | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/user/createWithList?'; + let url = `/user/createWithList?`; return this.$post( url, @@ -397,8 +391,7 @@ export class userService extends BaseService { deleteUser(username: string , config?: IRequestShortcutConfig ): IPromise { - let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$delete( url, @@ -413,8 +406,7 @@ export class userService extends BaseService { getUserByName(username: string , config?: IRequestShortcutConfig ): IPromise { - let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$get( url, @@ -431,7 +423,7 @@ export class userService extends BaseService { password: string | null | undefined, config?: IRequestShortcutConfig ): IPromise { - let url = '/user/login?'; + let url = `/user/login?`; if (username !== undefined) { url += serializeQueryParam(username, 'username') + "&"; } @@ -450,7 +442,7 @@ export class userService extends BaseService { */ logoutUser( config?: IRequestShortcutConfig ): IPromise { - let url = '/user/logout?'; + let url = `/user/logout?`; return this.$get( url, @@ -467,8 +459,7 @@ export class userService extends BaseService { username: string , config?: IRequestShortcutConfig ): IPromise { - let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$put( url, diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index 2c5a941..bd7031e 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -83,7 +83,7 @@ export class petService extends BaseService { body: Pet, config?: any ): Observable { - let url = '/pet?'; + let url = `/pet?`; return this.$post( url, @@ -102,8 +102,7 @@ export class petService extends BaseService { petId: number, config?: any ): Observable { - let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}?`; return this.$delete( url, @@ -119,7 +118,7 @@ export class petService extends BaseService { status: ("available" | "pending" | "sold") | null | undefined, config?: any ): Observable { - let url = '/pet/findByStatus?'; + let url = `/pet/findByStatus?`; if (status !== undefined && status !== null) { url += `status=${encodeURIComponent(`${status}`)}&`; } @@ -138,7 +137,7 @@ export class petService extends BaseService { tags: string[] | null | undefined, config?: any ): Observable { - let url = '/pet/findByTags?'; + let url = `/pet/findByTags?`; if (tags !== undefined && tags !== null) { url += `tags=${encodeURIComponent(`${tags}`)}&`; } @@ -157,8 +156,7 @@ export class petService extends BaseService { petId: number, config?: any ): Observable { - let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}?`; return this.$get( url, @@ -174,7 +172,7 @@ export class petService extends BaseService { body: Pet, config?: any ): Observable { - let url = '/pet?'; + let url = `/pet?`; return this.$put( url, @@ -195,8 +193,7 @@ export class petService extends BaseService { status: string | null | undefined, config?: any ): Observable { - let url = '/pet/{petId}?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}?`; if (name !== undefined && name !== null) { url += `name=${encodeURIComponent(`${name}`)}&`; } @@ -223,8 +220,7 @@ export class petService extends BaseService { additionalMetadata: string | null | undefined, config?: any ): Observable { - let url = '/pet/{petId}/uploadImage?'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + let url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage?`; if (additionalMetadata !== undefined && additionalMetadata !== null) { url += `additionalMetadata=${encodeURIComponent(`${additionalMetadata}`)}&`; } @@ -257,8 +253,7 @@ export class storeService extends BaseService { orderId: number, config?: any ): Observable { - let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + let url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; return this.$delete( url, @@ -272,7 +267,7 @@ export class storeService extends BaseService { getInventory( config?: any ): Observable<{ [key: string]: number }> { - let url = '/store/inventory?'; + let url = `/store/inventory?`; return this.$get( url, @@ -288,8 +283,7 @@ export class storeService extends BaseService { orderId: number, config?: any ): Observable { - let url = '/store/order/{orderId}?'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + let url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; return this.$get( url, @@ -305,7 +299,7 @@ export class storeService extends BaseService { body: Order | null | undefined, config?: any ): Observable { - let url = '/store/order?'; + let url = `/store/order?`; return this.$post( url, @@ -335,7 +329,7 @@ export class userService extends BaseService { body: User | null | undefined, config?: any ): Observable { - let url = '/user?'; + let url = `/user?`; return this.$post( url, @@ -352,7 +346,7 @@ export class userService extends BaseService { body: User[] | null | undefined, config?: any ): Observable { - let url = '/user/createWithList?'; + let url = `/user/createWithList?`; return this.$post( url, @@ -369,8 +363,7 @@ export class userService extends BaseService { username: string, config?: any ): Observable { - let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$delete( url, @@ -386,8 +379,7 @@ export class userService extends BaseService { username: string, config?: any ): Observable { - let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$get( url, @@ -405,7 +397,7 @@ export class userService extends BaseService { password: string | null | undefined, config?: any ): Observable { - let url = '/user/login?'; + let url = `/user/login?`; if (username !== undefined && username !== null) { url += `username=${encodeURIComponent(`${username}`)}&`; } @@ -425,7 +417,7 @@ export class userService extends BaseService { logoutUser( config?: any ): Observable { - let url = '/user/logout?'; + let url = `/user/logout?`; return this.$get( url, @@ -443,8 +435,7 @@ export class userService extends BaseService { username: string, config?: any ): Observable { - let url = '/user/{username}?'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + let url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$put( url, diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 643ed69..99b5624 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -30,8 +30,7 @@ export const petClient = { addPet( body: Pet , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet'; - + const url = `/pet`; return axios.request({ url: url, @@ -49,9 +48,7 @@ export const petClient = { petId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}'; - - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -69,8 +66,7 @@ export const petClient = { findPetsByStatus( status: ("available" | "pending" | "sold") | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/findByStatus'; - + const url = `/pet/findByStatus`; return axios.request({ url: url, @@ -88,8 +84,7 @@ export const petClient = { findPetsByTags( tags: string[] | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/findByTags'; - + const url = `/pet/findByTags`; return axios.request({ url: url, @@ -107,9 +102,7 @@ export const petClient = { getPetById( petId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}'; - - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -124,8 +117,7 @@ export const petClient = { updatePet( body: Pet , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet'; - + const url = `/pet`; return axios.request({ url: url, @@ -145,9 +137,7 @@ export const petClient = { status: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}'; - - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -170,9 +160,7 @@ export const petClient = { additionalMetadata: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/pet/{petId}/uploadImage'; - - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage`; return axios.request({ url: url, @@ -193,10 +181,9 @@ export const petClient = { export function usepetfindPetsByStatus( status: ("available" | "pending" | "sold") | null | undefined, $config?: SwrConfig ) { - let url = '/pet/findByStatus'; + const url = `/pet/findByStatus`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; if (!!status) { cacheUrl += `status=${status}&`; @@ -228,10 +215,9 @@ export function usepetfindPetsByStatus( status: ("available" | "pending" | "sol export function usepetfindPetsByTags( tags: string[] | null | undefined, $config?: SwrConfig ) { - let url = '/pet/findByTags'; + const url = `/pet/findByTags`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; if (!!tags) { cacheUrl += `tags=${tags}&`; @@ -263,11 +249,9 @@ export function usepetfindPetsByTags( tags: string[] | null | undefined, export function usepetPetById( petId: number , $config?: SwrConfig ) { - let url = '/pet/{petId}'; + const url = `/pet/${encodeURIComponent(`${petId}`)}`; const { axios: $axiosConf, key, ...config } = $config || {}; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); - let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( key ?? cacheUrl, @@ -293,9 +277,7 @@ const { data, error, mutate } = useSWR( deleteOrder( orderId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/store/order/{orderId}'; - - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; return axios.request({ url: url, @@ -308,8 +290,7 @@ const { data, error, mutate } = useSWR( */ getInventory( $config?: AxiosRequestConfig ): AxiosPromise<{ [key: string]: number }> { - let url = '/store/inventory'; - + const url = `/store/inventory`; return axios.request<{ [key: string]: number }>({ url: url, @@ -324,9 +305,7 @@ const { data, error, mutate } = useSWR( getOrderById( orderId: number , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/store/order/{orderId}'; - - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; return axios.request({ url: url, @@ -341,8 +320,7 @@ const { data, error, mutate } = useSWR( placeOrder( body: Order | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/store/order'; - + const url = `/store/order`; return axios.request({ url: url, @@ -358,10 +336,9 @@ const { data, error, mutate } = useSWR( */ export function usestoreInventory( $config?: SwrConfig ) { - let url = '/store/inventory'; + const url = `/store/inventory`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; const { data, error, mutate } = useSWR<{ [key: string]: number }>( key ?? cacheUrl, @@ -386,11 +363,9 @@ const { data, error, mutate } = useSWR<{ [key: string]: number }>( export function usestoreOrderById( orderId: number , $config?: SwrConfig ) { - let url = '/store/order/{orderId}'; + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; const { axios: $axiosConf, key, ...config } = $config || {}; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); - let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( key ?? cacheUrl, @@ -416,8 +391,7 @@ const { data, error, mutate } = useSWR( createUser( body: User | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user'; - + const url = `/user`; return axios.request({ url: url, @@ -433,8 +407,7 @@ const { data, error, mutate } = useSWR( createUsersWithListInput( body: User[] | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/createWithList'; - + const url = `/user/createWithList`; return axios.request({ url: url, @@ -450,9 +423,7 @@ const { data, error, mutate } = useSWR( deleteUser( username: string , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/{username}'; - - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return axios.request({ url: url, @@ -467,9 +438,7 @@ const { data, error, mutate } = useSWR( getUserByName( username: string , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/{username}'; - - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return axios.request({ url: url, @@ -486,8 +455,7 @@ const { data, error, mutate } = useSWR( password: string | null | undefined, $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/login'; - + const url = `/user/login`; return axios.request({ url: url, @@ -504,8 +472,7 @@ const { data, error, mutate } = useSWR( */ logoutUser( $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/logout'; - + const url = `/user/logout`; return axios.request({ url: url, @@ -522,9 +489,7 @@ const { data, error, mutate } = useSWR( username: string , $config?: AxiosRequestConfig ): AxiosPromise { - let url = '/user/{username}'; - - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return axios.request({ url: url, @@ -542,11 +507,9 @@ const { data, error, mutate } = useSWR( export function useuserUserByName( username: string , $config?: SwrConfig ) { - let url = '/user/{username}'; + const url = `/user/${encodeURIComponent(`${username}`)}`; const { axios: $axiosConf, key, ...config } = $config || {}; - url = url.replace('{username}', encodeURIComponent(`${username}`)); - let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( key ?? cacheUrl, @@ -573,10 +536,9 @@ export function useuserloginUser( username: string | null | undefined, password: string | null | undefined, $config?: SwrConfig ) { - let url = '/user/login'; + const url = `/user/login`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; if (!!username) { cacheUrl += `username=${username}&`; @@ -611,10 +573,9 @@ export function useuserloginUser( username: string | null | undefined, */ export function useuserlogoutUser( $config?: SwrConfig ) { - let url = '/user/logout'; + const url = `/user/logout`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; const { data, error, mutate } = useSWR( key ?? cacheUrl, diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 0c7608a..24109cb 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -22,7 +22,7 @@ export const petClient = { addPet(body: Pet , $config?: XiorRequestConfig ): Promise> { - let url = '/pet'; + const url = `/pet`; return http.request({ url: url, @@ -40,8 +40,7 @@ export const petClient = { petId: number , $config?: XiorRequestConfig ): Promise> { - let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return http.request({ url: url, @@ -59,7 +58,7 @@ export const petClient = { findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/pet/findByStatus'; + const url = `/pet/findByStatus`; return http.request({ url: url, @@ -77,7 +76,7 @@ export const petClient = { findPetsByTags(tags: string[] | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/pet/findByTags'; + const url = `/pet/findByTags`; return http.request({ url: url, @@ -95,8 +94,7 @@ export const petClient = { getPetById(petId: number , $config?: XiorRequestConfig ): Promise> { - let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return http.request({ url: url, @@ -111,7 +109,7 @@ export const petClient = { updatePet(body: Pet , $config?: XiorRequestConfig ): Promise> { - let url = '/pet'; + const url = `/pet`; return http.request({ url: url, @@ -131,8 +129,7 @@ export const petClient = { status: string | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/pet/{petId}'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}`; return http.request({ url: url, @@ -155,8 +152,7 @@ export const petClient = { additionalMetadata: string | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/pet/{petId}/uploadImage'; - url = url.replace('{petId}', encodeURIComponent(`${petId}`)); + const url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage`; return http.request({ url: url, @@ -178,8 +174,7 @@ export const storeClient = { deleteOrder(orderId: number , $config?: XiorRequestConfig ): Promise> { - let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; return http.request({ url: url, @@ -192,7 +187,7 @@ export const storeClient = { */ getInventory($config?: XiorRequestConfig ): Promise> { - let url = '/store/inventory'; + const url = `/store/inventory`; return http.request<{ [key: string]: number }>({ url: url, @@ -207,8 +202,7 @@ export const storeClient = { getOrderById(orderId: number , $config?: XiorRequestConfig ): Promise> { - let url = '/store/order/{orderId}'; - url = url.replace('{orderId}', encodeURIComponent(`${orderId}`)); + const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; return http.request({ url: url, @@ -223,7 +217,7 @@ export const storeClient = { placeOrder(body: Order | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/store/order'; + const url = `/store/order`; return http.request({ url: url, @@ -242,7 +236,7 @@ export const userClient = { createUser(body: User | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/user'; + const url = `/user`; return http.request({ url: url, @@ -258,7 +252,7 @@ export const userClient = { createUsersWithListInput(body: User[] | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/user/createWithList'; + const url = `/user/createWithList`; return http.request({ url: url, @@ -274,8 +268,7 @@ export const userClient = { deleteUser(username: string , $config?: XiorRequestConfig ): Promise> { - let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return http.request({ url: url, @@ -290,8 +283,7 @@ export const userClient = { getUserByName(username: string , $config?: XiorRequestConfig ): Promise> { - let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return http.request({ url: url, @@ -308,7 +300,7 @@ export const userClient = { password: string | null | undefined, $config?: XiorRequestConfig ): Promise> { - let url = '/user/login'; + const url = `/user/login`; return http.request({ url: url, @@ -325,7 +317,7 @@ export const userClient = { */ logoutUser($config?: XiorRequestConfig ): Promise> { - let url = '/user/logout'; + const url = `/user/logout`; return http.request({ url: url, @@ -342,8 +334,7 @@ export const userClient = { username: string , $config?: XiorRequestConfig ): Promise> { - let url = '/user/{username}'; - url = url.replace('{username}', encodeURIComponent(`${username}`)); + const url = `/user/${encodeURIComponent(`${username}`)}`; return http.request({ url: url, @@ -361,7 +352,7 @@ function serializeQueryParam(obj: any) { if (obj instanceof Date) return obj.toJSON(); if (typeof obj !== 'object' || Array.isArray(obj)) return obj; return Object.keys(obj) - .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) + .reduce((a: any, b) => a.push(`${b}=${obj[b]}`) && a, []) .join('&'); } From bb5c57a665f33f94efdcff79e2b5deaf4b73ecfd Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Sun, 7 Jul 2024 15:21:53 +0200 Subject: [PATCH 17/27] docs: improve docs --- .github/workflows/node.yml | 5 ++++ README.md | 59 ++++++++++++++++++++++++++++---------- package.json | 17 +++++++---- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index cd69ad8..d1b33a0 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -27,3 +27,8 @@ jobs: - run: yarn build - run: yarn test - run: node dist/cli.js -c test/ci-test.config.json + - run: yarn coverage + + - name: Update Coverage Badge + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + uses: we-cli/coverage-badge-action@main diff --git a/README.md b/README.md index edee1c9..bc8981f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ -Generate ES6 or Typescript code from an OpenAPI 2.0 spec, so that accessing REST API resources from the client code is less error-prone, static-typed and just easier to use long-term. +Generate ES6 or Typescript code from an OpenAPI 3.0 spec, so that accessing REST API resources from the client code is less error-prone, static-typed and just easier to use long-term. You can take a look at the [Examples section](#example) down below. @@ -29,6 +29,19 @@ Or globally to run CLI from anywhere npm install swaggie -g +## OpenAPI versions + +Swaggie from version 1.0 supports OpenAPI 3.0 (and some features of 3.1). Swagger or OpenAPI v2 documents are not supported anymore, but you have few options how to deal with it: + +- **(preferred)** From your backend server generate OpenAPI 3.0 spec instead of version 2 (samples are updated to use OpenAPI 3.0) +- Convert your OpenAPI 2.0 spec to 3.0 using [swagger2openapi](https://www.npmjs.com/package/swagger2openapi) tool (or something similar) +- If you can't do that for any reason, you can stick to `Swaggie v0.x`. But upgrade is suggested + +Please note that OpenAPI 3.0 is a major spec upgrade and it's possible that there will be some breaking changes in the generated code. +I have tried my best to minimize the impact, but it was not possible to avoid it completely. + +More info about breaking changes can be found in the [Releases](https://github.com/yhnavein/swaggie/releases). + ### CLI ``` @@ -50,7 +63,7 @@ Options: Sample CLI usage using Swagger's Pet Store: ```bash -swaggie -s https://petstore.swagger.io/v2/swagger.json -o ./client/petstore/ +swaggie -s https://petstore3.swagger.io/api/v3/openapi.json -o ./client/petstore/ ``` `swaggie` outputs TypeScript that is somehow formatted, but it's far from perfect. You can adjust the generated code by prettifying output using your preferred beautify tool using your repo's styling guidelines. For example involving `prettier` looks like this: @@ -71,7 +84,7 @@ Sample configuration looks like this: { "$schema": "https://raw.githubusercontent.com/yhnavein/swaggie/master/schema.json", "out": "./src/client/petstore.ts", - "src": "https://petstore.swagger.io/v2/swagger.json", + "src": "https://petstore3.swagger.io/api/v3/openapi.json", "template": "axios", "baseUrl": "/api", "preferAny": true, @@ -96,7 +109,7 @@ ng2 Template for Angular 2+ (uses HttpClient, InjectionTokens, etc) If you want to use your own template, you can use the path to your template for the `-t` parameter: ``` -swaggie -s https://petstore.swagger.io/v2/swagger.json -o ./client/petstore --template ./my-swaggie-template/ +swaggie -s https://petstore3.swagger.io/api/v3/openapi.json -o ./client/petstore --template ./my-swaggie-template/ ``` ### Code @@ -105,7 +118,7 @@ swaggie -s https://petstore.swagger.io/v2/swagger.json -o ./client/petstore --te const swaggie = require('swaggie'); swaggie .genCode({ - src: 'http://petstore.swagger.io/v2/swagger.json', + src: 'https://petstore3.swagger.io/api/v3/openapi.json', out: './api/petstore.ts', }) .then(complete, error); @@ -149,7 +162,7 @@ You are not limited to any of these, but in our examples we will use Prettier. P Let's run `swaggie` against PetStore API and see what will happen: ```bash -swaggie -s https://petstore.swagger.io/v2/swagger.json -o ./api/petstore.ts && prettier ./api/petstore.ts --write +swaggie -s https://petstore3.swagger.io/api/v3/openapi.json -o ./api/petstore.ts && prettier ./api/petstore.ts --write ``` ```typescript @@ -206,17 +219,33 @@ You might wonder how to set up server to fully utilize Swaggie's features. For t Server is not necessary to use Swaggie. Swaggie cares only about the JSON/yaml file with the Open API spec, but for your development purpose you might want to have a server that can serve this file automatically from the actual endpoints. -## Notes +## Competitors If you are familiar with the client-code generators for the Swagger / OpenAPI standards then you might wonder why `swaggie` is better than existing tools. Currently the most popular alternative is an open-source `NSwag`. Quick comparison table: -| swaggie | NSwag | -| --------------------------------------------------------------- | ----------------------------------------------------------- | -| - Written in node.js + TypeScript | - Written in .NET | -| - Fast | - Slow | -| - ![swaggie size](https://packagephobia.now.sh/badge?p=swaggie) | - ![nswag size](https://packagephobia.now.sh/badge?p=nswag) | -| - Easy to contribute to | - Contributing hard | -| - Lightweight | - Complicated templates | -| - Only features generating API clients for TS/JS | - Many more features (but mostly for .NET apps) | +| swaggie | NSwag | Hey API | +| ------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | +| Written in node.js + TypeScript | Written in .NET | Written in TypeScript | +| Fast | Slow | Fast | +| ![swaggie size](https://packagephobia.now.sh/badge?p=swaggie) | ![nswag size](https://packagephobia.now.sh/badge?p=nswag) | ![nswag size](https://packagephobia.now.sh/badge?p=%40hey-api%2Fopenapi-ts) | +| Easy to contribute to | Contributing hard | Does not allow custom templates, so change is hard | +| Lightweight | Complicated templates | Generates a lot of code and multiple files | +| Flexible, suits well in the existing apps | Flexible, suits well in the existing apps | Enforces usage of other tools and architecture | +| Generates REST clients and all models | Many more features (but mostly for .NET apps) | No flexibility, other clients are discouraged from use | + +## Notes + +| Supported | Not supported | +| ------------------------------------------------------------------------------ | ------------------------------------------ | +| OpenAPI 3 | Swagger 2 | +| `allOf`, `oneOf`, `anyOf`, `$ref` to schemas | `not` | +| Spec formats: `JSON`, `YAML` | Very complex query params | +| Extensions: `x-position`, `x-name`, `x-enumNames`, `x-enum-varnames` | Multiple response types (one will be used) | +| Content types: `JSON`, `text`, `multipart/form-data` | Multiple request types (one will be used) | +| Content types: `application/x-www-form-urlencoded`, `application/octet-stream` | | +| Different types of enum definitions (+ OpenAPI 3.1 support for enums) | | +| Paths inheritance, comments (descriptions) | | +| Getting documents from remote locations or as path reference (local file) | | +| Grouping endpoints by tags + handle gracefully duplicate operation ids | | diff --git a/package.json b/package.json index 97bb62a..e7e8fad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "swaggie", - "version": "0.8.5", - "description": "Generate ES6 or TypeScript service integration code from an OpenAPI spec", + "version": "1.0.0-beta.0", + "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", "url": "https://github.com/yhnavein" @@ -16,7 +16,7 @@ "url": "https://github.com/yhnavein/swaggie/issues" }, "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "main": "dist/index.js", "types": "dist/index.d.ts", @@ -27,7 +27,8 @@ "build": "sucrase ./src -d ./dist --transforms typescript,imports && npm run rm-tests && npm run types", "rm-tests": "find dist/ -name '*.spec.js' -type f -delete", "types": "tsc src/types.ts --outDir dist/ --declaration --emitDeclarationOnly && cp test/index.d.ts ./dist/", - "test": "mocha" + "test": "mocha", + "coverage": "npx nyc -r text -r json-summary mocha" }, "files": [ "dist", @@ -35,9 +36,15 @@ ], "keywords": [ "swagger", - "swagger 2.0", "openapi", + "openapi 3.0", "rest", + "rest client", + "fetch", + "axios", + "angular", + "xior", + "swr", "service", "typescript", "codegen" From d86a2abe69f437dc92ee2c63bfb4f1ea6b9c5d92 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Mon, 8 Jul 2024 14:23:54 +0200 Subject: [PATCH 18/27] chore: experiment with aspnet core samples to test new approach for complex query types in ASP.NET Core --- samples/dotnetcore/nswag/README.md | 8 +++- samples/dotnetcore/nswag/Swaggie.Nswag/.env | 1 - .../Controllers/UserController.cs | 13 ++++++ .../dotnetcore/nswag/Swaggie.Nswag/Program.cs | 2 +- .../dotnetcore/nswag/Swaggie.Nswag/Startup.cs | 24 +++++++--- .../nswag/Swaggie.Nswag/Swaggie.Nswag.csproj | 4 +- .../Controllers/UserController.cs | 45 +++++++------------ .../Swaggie.Swashbuckle/QueryFilter.cs | 12 ++--- .../Swaggie.Swashbuckle/Startup.cs | 6 +++ 9 files changed, 71 insertions(+), 44 deletions(-) delete mode 100644 samples/dotnetcore/nswag/Swaggie.Nswag/.env diff --git a/samples/dotnetcore/nswag/README.md b/samples/dotnetcore/nswag/README.md index 2bc2db3..f69d978 100644 --- a/samples/dotnetcore/nswag/README.md +++ b/samples/dotnetcore/nswag/README.md @@ -2,7 +2,13 @@ ## ASP.NET Core NSwag configuration -You can open `Swaggie.Nswag/Swaggie.Nswag.sln` in Rider or VS to see the sample ASP.NET Core project with NSwag configured. It is working out of the box and it requires `dotnet 6.0`. It should be compatible with other dotnet versions as well. +You can open `Swaggie.Nswag/Swaggie.Nswag.sln` in Rider or VS to see the sample ASP.NET Core project with NSwag configured. It is working out of the box and it requires `dotnet 8.0`. It should be compatible with other dotnet versions as well. + +## Concerns + +If you use complex query parameter types, then I suggest you strongly to migrate away from NSwag to Swashbuckle. +Both are generating wrong specs, but for Swashbuckle it's possible to fix it by using custom `OperationFilter`. +NSwag offers same extensibility, but in practice it's not working as expected (or, it's very hard to make it work). ## Swaggie result diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/.env b/samples/dotnetcore/nswag/Swaggie.Nswag/.env deleted file mode 100644 index f0e9f92..0000000 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/.env +++ /dev/null @@ -1 +0,0 @@ -ASPNETCORE_ENVIRONMENT=dev \ No newline at end of file diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs b/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs index 1e33700..ed35d3c 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Controllers/UserController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -55,6 +56,18 @@ public class UserViewModel public string Email { get; set; } public UserRole Role { get; set; } + + [Required] + public Dictionary SomeDict { get; set; } = new(); + + public PagedResult AuditEvents { get; set; } = new(); +} + +public class PagedResult +{ + public IList Items { get; set; } + + public int TotalCount { get; set; } } public enum UserRole diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs b/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs index 4d3f234..1ed076f 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Program.cs @@ -3,7 +3,7 @@ namespace Swaggie.Nswag; -public class Program +public static class Program { public static void Main(string[] args) { diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs b/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs index 4b1ee3d..21ed4b6 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Startup.cs @@ -42,9 +42,8 @@ public void ConfigureServices(IServiceCollection services) if (!_isProduction) { - services.AddSwaggerDocument(c => + services.AddOpenApiDocument(c => { - // c.GenerateEnumMappingDescription = true; c.PostProcess = document => { document.Info.Version = "v1"; @@ -68,10 +67,25 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, if (!_isProduction) { app.UseOpenApi(); - app.UseSwaggerUi3(c => + app.UseSwaggerUi(c => { - c.Path = "/swagger"; - c.DocumentPath = "/swagger/v1/swagger.json"; + c.DocExpansion = "list"; + c.DefaultModelsExpandDepth = 1; + }); + app.UseReDoc(c => + { + c.Path = "/redoc"; + }); + + // Redirect root to Swagger UI + app.Use(async (context, next) => + { + if (context.Request.Path.Value == "/") + { + context.Response.Redirect("/swagger"); + return; + } + await next(); }); } diff --git a/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj b/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj index 15f10a3..42b2d8e 100644 --- a/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj +++ b/samples/dotnetcore/nswag/Swaggie.Nswag/Swaggie.Nswag.csproj @@ -10,7 +10,7 @@ - - + + diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs index 2139b71..3eacc36 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs @@ -35,37 +35,17 @@ public IActionResult GetUsers([FromQuery] UserRole? role) } [HttpGet("filter")] - [Produces(typeof(PagedResult))] - public IActionResult FilterUsers([FromQuery(Name = "filter")] UserFilter? filter, [FromQuery(Name = "secondFilter")] UserFilter? secondFilter, [FromQuery] Dictionary someDict) + [Produces(typeof(FilterTestResponse))] + public IActionResult TestFilters([FromQuery(Name = "filter")] UserFilter? filter, [FromQuery(Name = "secondFilter")] UserFilter? secondFilter, [FromQuery] Dictionary someDict) { - Console.WriteLine(JsonConvert.SerializeObject(filter)); - Console.WriteLine(JsonConvert.SerializeObject(secondFilter)); - var allUsers = new[] - { - new UserViewModel - { - Name = "Ann Bobcat", Id = 1, Email = "ann.b@test.org", Role = UserRole.Admin - }, - new UserViewModel - { - Name = "Bob Johnson", Id = 2, Email = "bob.j@test.org", Role = UserRole.User - } - }; - - if (filter == null) - { - return Ok(allUsers); - } - - var users = allUsers - .Where(u => filter.Roles.Count > 0 || filter.Roles.Contains(u.Role)) - // Rest of the filtering logic - .ToList(); - - var result = new PagedResult + Console.WriteLine("filter: " + JsonConvert.SerializeObject(filter)); + Console.WriteLine("secondFilter: " + JsonConvert.SerializeObject(secondFilter)); + Console.WriteLine("someDict: " + JsonConvert.SerializeObject(someDict)); + var result = new FilterTestResponse { - Items = users, - TotalCount = users.Count + filter = filter, + secondFilter = secondFilter, + someDict = someDict }; return Ok(result); @@ -158,3 +138,10 @@ public enum UserRole User = 1, Guest = 2 } + +public class FilterTestResponse +{ + public UserFilter? filter { get; set; } + public UserFilter? secondFilter { get; set; } + public Dictionary someDict { get; set; } +} diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs index 9592097..36570cf 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs @@ -4,9 +4,11 @@ using Swashbuckle.AspNetCore.SwaggerGen; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace Swaggie.Swashbuckle; +// ReSharper disable once ClassNeverInstantiated.Global public class FromQueryModelFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) @@ -19,7 +21,9 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context) return; } - var actionParameters = description.ActionDescriptor.Parameters; + var actionParameters = description.ActionDescriptor.Parameters + .Where(p => p.ParameterType != typeof(CancellationToken)) + .ToList(); var apiParameters = description.ParameterDescriptions .Where(p => p.Source.IsFromRequest) .ToList(); @@ -46,7 +50,7 @@ private List CreateParameters( return newParameters.Count != 0 ? newParameters : null; } - private OpenApiParameter CreateParameter( + private static OpenApiParameter CreateParameter( ParameterDescriptor actionParameter, IList operationParameters, OperationFilterContext context) @@ -67,7 +71,7 @@ private OpenApiParameter CreateParameter( context.SchemaGenerator.GenerateSchema(actionParameter.ParameterType, context.SchemaRepository); - var newParameter = new OpenApiParameter + return new OpenApiParameter { Name = actionParameter.Name, In = ParameterLocation.Query, @@ -75,7 +79,5 @@ private OpenApiParameter CreateParameter( Explode = true, Style = ParameterStyle.Simple }; - - return newParameter; } } diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs index 412c8d0..aaa8113 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs @@ -66,6 +66,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, } app.UseRouting(); + app.UseCors(c => + { + c.AllowAnyHeader() + .AllowAnyMethod() + .AllowAnyOrigin(); + }); if (!_isProduction) { From cfddb7c4181d6708d6d49153c1592225a1c726ef Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Mon, 8 Jul 2024 20:50:44 +0200 Subject: [PATCH 19/27] fix: properly serialize query params (using ASPNET way for now) --- .vscode/settings.json | 3 + src/utils/paramSerializer.spec.ts | 70 +++++++++++ .../serializeQueryParam.angular1.spec.ts | 63 ---------- src/utils/serializeQueryParam.spec.ts | 73 ----------- templates/axios/barrel.ejs | 39 ++++-- templates/axios/baseClient.ejs | 3 +- templates/axios/operation.ejs | 2 +- templates/fetch/barrel.ejs | 39 ++++-- templates/fetch/baseClient.ejs | 1 + templates/fetch/operation.ejs | 17 +-- templates/ng1/baseClient.ejs | 2 +- templates/ng2/barrel.ejs | 35 ++++++ templates/ng2/baseClient.ejs | 2 +- templates/ng2/operation.ejs | 17 +-- templates/swr-axios/barrel.ejs | 40 ++++-- templates/swr-axios/baseClient.ejs | 3 +- templates/swr-axios/operation.ejs | 2 +- templates/swr-axios/swrOperation.ejs | 2 +- templates/xior/barrel.ejs | 39 ++++-- templates/xior/baseClient.ejs | 1 + templates/xior/operation.ejs | 2 +- test/snapshots/axios.ts | 56 ++++++--- test/snapshots/fetch.ts | 116 ++++++++++-------- test/snapshots/ng1.ts | 2 +- test/snapshots/ng2.ts | 113 ++++++++++------- test/snapshots/swr-axios.ts | 65 +++++++--- test/snapshots/xior.ts | 54 +++++--- 27 files changed, 518 insertions(+), 343 deletions(-) create mode 100644 src/utils/paramSerializer.spec.ts delete mode 100644 src/utils/serializeQueryParam.angular1.spec.ts delete mode 100644 src/utils/serializeQueryParam.spec.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 92cea30..4b10690 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,8 @@ "files.associations": { "templates/**/*.ejs": "plaintext" + }, + "[html]": { + "editor.formatOnSave": false } } diff --git a/src/utils/paramSerializer.spec.ts b/src/utils/paramSerializer.spec.ts new file mode 100644 index 0000000..5e0ca40 --- /dev/null +++ b/src/utils/paramSerializer.spec.ts @@ -0,0 +1,70 @@ +import { expect } from 'chai'; + +/** This file tests a code that will be generated and is hardcoded into the templates */ + +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); +} + +const date = new Date('2020-04-16T00:00:00.000Z'); + +describe('paramsSerializer', () => { + const testCases = [ + { input: '', expected: '' }, + { input: null, expected: '' }, + { input: undefined, expected: '' }, + { input: {}, expected: '' }, + { input: { a: 1, b: 'test' }, expected: 'a=1&b=test' }, + { input: { a: 1, b: [1, 2, 3, 'test'] }, expected: 'a=1&b=1&b=2&b=3&b=test' }, + { input: { a: { b: { c: 1, d: 'test' } } }, expected: 'a.b.c=1&a.b.d=test' }, + { + input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, + expected: 'a.b.d=test&a.b.f=', + }, + { input: { a: [1, 2, 3] }, expected: 'a=1&a=2&a=3' }, + { input: { a: date }, expected: 'a=2020-04-16T00%3A00%3A00.000Z' }, + { input: { a: [date] }, expected: 'a=2020-04-16T00%3A00%3A00.000Z' }, + { + input: { a: [date, date] }, + expected: 'a=2020-04-16T00%3A00%3A00.000Z&a=2020-04-16T00%3A00%3A00.000Z', + }, + ]; + + for (const el of testCases) { + it(`should handle ${JSON.stringify(el.input)}`, () => { + const res = paramsSerializer(el.input); + + expect(res).to.be.equal(el.expected); + }); + } +}); diff --git a/src/utils/serializeQueryParam.angular1.spec.ts b/src/utils/serializeQueryParam.angular1.spec.ts deleted file mode 100644 index 636b4df..0000000 --- a/src/utils/serializeQueryParam.angular1.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect } from 'chai'; -/** This file tests a code that will be generated and is hardcoded into the templates */ - -function serializeQueryParam(obj: any, property: string): string { - if (obj === null || obj === undefined || obj === '') { - return ''; - } - if (obj instanceof Date) { - return `${property}=${encodeURIComponent(obj.toJSON())}`; - } - if (Array.isArray(obj)) { - return Object.values(obj) - .map((value) => `${property}[]=${value}`) - .join('&'); - } - if (typeof obj !== 'object') { - return `${property}=${encodeURIComponent(obj)}`; - } - if (typeof obj === 'object') { - return Object.keys(obj) - .filter((key) => !!serializeQueryParam(obj[key], `${property}.${key}`)) - .reduce((a: any, b) => a.push(serializeQueryParam(obj[b], `${property}.${b}`)) && a, []) - .join('&'); - } - - return ''; -} - -describe('serializeQueryParam.angular1', () => { - const testCases = [ - { input: '', property: 'page', expected: '' }, - { input: null, property: 'page', expected: '' }, - { input: undefined, property: 'page', expected: '' }, - { input: 123, property: 'page', expected: 'page=123' }, - { input: 'test string', property: 'name', expected: 'name=test%20string' }, - { input: {}, property: 'filter', expected: '' }, - { input: { a: 1, b: 'test' }, property: 'filter', expected: 'filter.a=1&filter.b=test' }, - { - input: { a: 1, b: [1, 2, 3, 'test'] }, - property: 'filter', - expected: 'filter.a=1&filter.b[]=1&filter.b[]=2&filter.b[]=3&filter.b[]=test', - }, - { input: [1, 2, 3], property: 'property', expected: 'property[]=1&property[]=2&property[]=3' }, - { - input: new Date('2020-04-16T00:00:00.000Z'), - property: 'property', - expected: 'property=2020-04-16T00%3A00%3A00.000Z', - }, - { - input: { name: 'John', agentId: 7 }, - property: 'filter', - expected: 'filter.name=John&filter.agentId=7', - }, - ]; - - for (const el of testCases) { - it(`should handle ${JSON.stringify(el.input)} with property ${el.property}`, () => { - const res = serializeQueryParam(el.input, el.property); - - expect(res).to.be.equal(el.expected); - }); - } -}); diff --git a/src/utils/serializeQueryParam.spec.ts b/src/utils/serializeQueryParam.spec.ts deleted file mode 100644 index c64d84c..0000000 --- a/src/utils/serializeQueryParam.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { expect } from 'chai'; -/** This file tests a code that will be generated and is hardcoded into the templates */ - -describe('serializeQueryParam', () => { - const testCases = [ - { input: '', expected: '' }, - { input: null, expected: '' }, - { input: undefined, expected: '' }, - { input: 123, expected: '123' }, - { input: 'test string', expected: 'test%20string' }, - { input: {}, expected: '' }, - { input: { a: 1, b: 'test' }, expected: 'a=1&b=test' }, - { input: { a: 1, b: [1, 2, 3, 'test'] }, expected: 'a=1&b=1%2C2%2C3%2Ctest' }, - { input: [1, 2, 3], expected: '1%2C2%2C3' }, - { input: new Date('2020-04-16T00:00:00.000Z'), expected: '2020-04-16T00%3A00%3A00.000Z' }, - ]; - - for (const el of testCases) { - it(`should handle ${JSON.stringify(el.input)}`, () => { - const res = serializeQueryParam(el.input); - - expect(res).to.be.equal(el.expected); - }); - } - - function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return encodeURIComponent(obj.toJSON()); - if (typeof obj !== 'object' || Array.isArray(obj)) return encodeURIComponent(obj); - return Object.keys(obj) - .reduce( - (a, b) => a.push(`${encodeURIComponent(b)}=${encodeURIComponent(obj[b])}`) && a, - [] - ) - .join('&'); - } -}); - -/** This is different, because we don't need to encode parameters for axios as axios is doing it on its own */ -describe('serializeQueryParam / axios', () => { - for (const el of [ - { input: '', expected: '' }, - { input: null, expected: '' }, - { input: undefined, expected: '' }, - { input: 123, expected: 123 }, - { input: 'test string', expected: 'test string' }, - { input: {}, expected: '' }, - { input: { a: 1, b: 'test' }, expected: 'a=1&b=test' }, - { input: { a: 1, b: [1, 2, 3, 'test'] }, expected: 'a=1&b=1,2,3,test' }, - { input: new Date('2020-04-16T00:00:00.000Z'), expected: '2020-04-16T00:00:00.000Z' }, - ]) { - it(`should handle ${JSON.stringify(el.input)}`, () => { - const res = serializeQueryParam(el.input); - - expect(res).to.eq(el.expected); - }); - } - - it('should handle array', () => { - const res = serializeQueryParam([1, 2, 3]); - - expect(res.toString()).to.be.equal([1, 2, 3].toString()); - }); - - function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a, b) => a.push(`${b}=${obj[b]}`) && a, []) - .join('&'); - } -}); diff --git a/templates/axios/barrel.ejs b/templates/axios/barrel.ejs index c82c1b2..1d1fdf6 100644 --- a/templates/axios/barrel.ejs +++ b/templates/axios/barrel.ejs @@ -1,10 +1,35 @@ -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } diff --git a/templates/axios/baseClient.ejs b/templates/axios/baseClient.ejs index 31866e0..e9c7539 100644 --- a/templates/axios/baseClient.ejs +++ b/templates/axios/baseClient.ejs @@ -9,9 +9,10 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { AxiosPromise, AxiosRequestConfig } from "axios"; +import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' %>', + paramsSerializer: (params: any) => paramsSerializer(params), }); diff --git a/templates/axios/operation.ejs b/templates/axios/operation.ejs index 37bdb32..bfeae60 100644 --- a/templates/axios/operation.ejs +++ b/templates/axios/operation.ejs @@ -24,7 +24,7 @@ $config?: AxiosRequestConfig <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> - '<%= parameter.originalName %>': serializeQueryParam(<%= parameter.name %>), + '<%= parameter.originalName %>': <%= parameter.name %>, <% }); %> }, <% } %> diff --git a/templates/fetch/barrel.ejs b/templates/fetch/barrel.ejs index c4a6c8f..1d1fdf6 100644 --- a/templates/fetch/barrel.ejs +++ b/templates/fetch/barrel.ejs @@ -1,10 +1,35 @@ -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return encodeURIComponent(obj.toJSON()); - if (typeof obj !== 'object' || Array.isArray(obj)) return encodeURIComponent(obj); - return Object.keys(obj) - .reduce((a: any, b) => a.push(`${encodeURIComponent(b)}=${encodeURIComponent(obj[b])}`) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } diff --git a/templates/fetch/baseClient.ejs b/templates/fetch/baseClient.ejs index d14cb05..9943872 100644 --- a/templates/fetch/baseClient.ejs +++ b/templates/fetch/baseClient.ejs @@ -11,5 +11,6 @@ export const defaults = { baseUrl: '<%= it.baseUrl || '' %>', + paramsSerializer: (params: any) => paramsSerializer(params), }; diff --git a/templates/fetch/operation.ejs b/templates/fetch/operation.ejs index 0cecf0d..eedb338 100644 --- a/templates/fetch/operation.ejs +++ b/templates/fetch/operation.ejs @@ -9,18 +9,11 @@ <% }); %> $config?: RequestInit ): Promise<<%~ it.returnType %>> { - let url = `${defaults.baseUrl}<%= it.url %>?`; -<% if(it.query && it.query.length > 0) { %> - <% it.query.forEach((parameter) => { %> - if (<%= parameter.name %> !== undefined) { - <% if(!!parameter.original && parameter.original.type === 'array') { %> - <%= parameter.name %>.forEach(item => { url += `<%= parameter.originalName %>=${serializeQueryParam(item)}&`; }); - <% } else {%> - url += `<%= parameter.originalName %>=${serializeQueryParam(<%= parameter.name %>)}&`; - <% } %> - } - <% }); %> -<% } %> + const url = `${defaults.baseUrl}<%= it.url %>?<% + if(it.query && it.query.length > 0) { %>${defaults.paramsSerializer({<% + it.query.forEach((parameter) => { %> +'<%= parameter.originalName %>': <%= parameter.name %>, + <% }); %>})}<% } %>`; return fetch(url, { method: '<%= it.method %>', diff --git a/templates/ng1/baseClient.ejs b/templates/ng1/baseClient.ejs index 7642ae3..0e4fa1d 100644 --- a/templates/ng1/baseClient.ejs +++ b/templates/ng1/baseClient.ejs @@ -9,7 +9,7 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import { IHttpService, IRequestShortcutConfig, IPromise } from 'angular'; +import type { IHttpService, IRequestShortcutConfig, IPromise } from 'angular'; abstract class BaseService { constructor(protected readonly $http: IHttpService, public baseUrl: string) { } diff --git a/templates/ng2/barrel.ejs b/templates/ng2/barrel.ejs index e69de29..1d1fdf6 100644 --- a/templates/ng2/barrel.ejs +++ b/templates/ng2/barrel.ejs @@ -0,0 +1,35 @@ + +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); +} + diff --git a/templates/ng2/baseClient.ejs b/templates/ng2/baseClient.ejs index 8acd277..b4ee26a 100644 --- a/templates/ng2/baseClient.ejs +++ b/templates/ng2/baseClient.ejs @@ -9,7 +9,7 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import { Observable } from "rxjs"; +import type { Observable } from "rxjs"; import { Injectable, Inject, Optional, InjectionToken } from "@angular/core"; import { HttpClient } from "@angular/common/http"; diff --git a/templates/ng2/operation.ejs b/templates/ng2/operation.ejs index 1f9c36d..ff5c2e3 100644 --- a/templates/ng2/operation.ejs +++ b/templates/ng2/operation.ejs @@ -11,18 +11,11 @@ <% }); %> config?: any ): Observable<<%~ it.returnType %>> { - let url = `<%= it.url %>?`; -<% if(it.query && it.query.length > 0) { %> - <% it.query.forEach((parameter) => { %> - if (<%= parameter.name %> !== undefined && <%= parameter.name %> !== null) { - <% if(parameter.original?.type === 'array') { %> - <%= parameter.name %>.forEach(item => { url += `<%= parameter.originalName %>=${encodeURIComponent(`${item}`)}&`; }); - <% } else {%> - url += `<%= parameter.originalName %>=${encodeURIComponent(`${<%= parameter.name %>}`)}&`; - <% } %> - } - <% }); %> -<% } %> + const url = `<%= it.url %>?<% + if(it.query && it.query.length > 0) { %>${paramsSerializer({<% + it.query.forEach((parameter) => { %> +'<%= parameter.originalName %>': <%= parameter.name %>, + <% }); %>})}<% } %>`; return this.$<%= it.method.toLowerCase() %>( url, diff --git a/templates/swr-axios/barrel.ejs b/templates/swr-axios/barrel.ejs index 057c593..1d1fdf6 100644 --- a/templates/swr-axios/barrel.ejs +++ b/templates/swr-axios/barrel.ejs @@ -1,9 +1,35 @@ -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } + diff --git a/templates/swr-axios/baseClient.ejs b/templates/swr-axios/baseClient.ejs index 1d01eed..9c5be83 100644 --- a/templates/swr-axios/baseClient.ejs +++ b/templates/swr-axios/baseClient.ejs @@ -9,11 +9,12 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { AxiosPromise, AxiosRequestConfig } from "axios"; +import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; import useSWR, { SWRConfiguration, Key } from 'swr'; export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' -%>', + paramsSerializer: (params: any) => paramsSerializer(params), }); interface SwrConfig extends SWRConfiguration { diff --git a/templates/swr-axios/operation.ejs b/templates/swr-axios/operation.ejs index 3edcb3b..fce67dd 100644 --- a/templates/swr-axios/operation.ejs +++ b/templates/swr-axios/operation.ejs @@ -24,7 +24,7 @@ <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> - '<%= parameter.originalName %>': serializeQueryParam(<%= parameter.name %>), + '<%= parameter.originalName %>': <%= parameter.name %>, <% }); %> }, <% } %> diff --git a/templates/swr-axios/swrOperation.ejs b/templates/swr-axios/swrOperation.ejs index 32b83e9..f805cd4 100644 --- a/templates/swr-axios/swrOperation.ejs +++ b/templates/swr-axios/swrOperation.ejs @@ -29,7 +29,7 @@ const { data, error, mutate } = useSWR<<%~ it.returnType %>>( <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> - '<%= parameter.originalName %>': serializeQueryParam(<%= parameter.name %>), + '<%= parameter.originalName %>': <%= parameter.name %>, <% }); %> }, <% } %> diff --git a/templates/xior/barrel.ejs b/templates/xior/barrel.ejs index 1f801bd..1d1fdf6 100644 --- a/templates/xior/barrel.ejs +++ b/templates/xior/barrel.ejs @@ -1,10 +1,35 @@ -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a: any, b) => a.push(`${b}=${obj[b]}`) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } diff --git a/templates/xior/baseClient.ejs b/templates/xior/baseClient.ejs index 1e0e42d..d8ead04 100644 --- a/templates/xior/baseClient.ejs +++ b/templates/xior/baseClient.ejs @@ -13,5 +13,6 @@ import xior, { type XiorResponse, type XiorRequestConfig } from "xior"; export const http = xior.create({ baseURL: '<%= it.baseUrl || '' %>', + paramsSerializer: (params) => paramsSerializer(params), }); diff --git a/templates/xior/operation.ejs b/templates/xior/operation.ejs index a7a1b8c..f087ffa 100644 --- a/templates/xior/operation.ejs +++ b/templates/xior/operation.ejs @@ -24,7 +24,7 @@ $config?: XiorRequestConfig <% if(it.query && it.query.length > 0) { %> params: { <% it.query.forEach((parameter) => { %> - '<%= parameter.originalName %>': serializeQueryParam(<%= parameter.name %>), + '<%= parameter.originalName %>': <%= parameter.name %>, <% }); %> }, <% } %> diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index 7af17c3..c376cb4 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -9,10 +9,11 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { AxiosPromise, AxiosRequestConfig } from "axios"; +import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; export const axios = Axios.create({ baseURL: '', + paramsSerializer: (params: any) => paramsSerializer(params), }); export const petClient = { @@ -64,7 +65,7 @@ export const petClient = { url: url, method: 'GET', params: { - 'status': serializeQueryParam(status), + 'status': status, }, ...$config, }); @@ -82,7 +83,7 @@ export const petClient = { url: url, method: 'GET', params: { - 'tags': serializeQueryParam(tags), + 'tags': tags, }, ...$config, }); @@ -135,8 +136,8 @@ export const petClient = { url: url, method: 'POST', params: { - 'name': serializeQueryParam(name), - 'status': serializeQueryParam(status), + 'name': name, + 'status': status, }, ...$config, }); @@ -159,7 +160,7 @@ export const petClient = { method: 'POST', data: body, params: { - 'additionalMetadata': serializeQueryParam(additionalMetadata), + 'additionalMetadata': additionalMetadata, }, ...$config, }); @@ -306,8 +307,8 @@ export const userClient = { url: url, method: 'GET', params: { - 'username': serializeQueryParam(username), - 'password': serializeQueryParam(password), + 'username': username, + 'password': password, }, ...$config, }); @@ -347,13 +348,38 @@ export const userClient = { }; -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } export interface Order { diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index 6d4f86e..c57086e 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -11,6 +11,7 @@ export const defaults = { baseUrl: '', + paramsSerializer: (params: any) => paramsSerializer(params), }; export const petClient = { @@ -20,7 +21,7 @@ export const petClient = { addPet(body: Pet , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet?`; + const url = `${defaults.baseUrl}/pet?`; return fetch(url, { method: 'POST', @@ -38,7 +39,7 @@ export const petClient = { petId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; + const url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; return fetch(url, { method: 'DELETE', @@ -56,11 +57,9 @@ export const petClient = { findPetsByStatus(status: ("available" | "pending" | "sold") | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/findByStatus?`; - if (status !== undefined) { - url += `status=${serializeQueryParam(status)}&`; - } - + const url = `${defaults.baseUrl}/pet/findByStatus?${defaults.paramsSerializer({'status': status, + })}`; + return fetch(url, { method: 'GET', ...$config, @@ -74,11 +73,9 @@ export const petClient = { findPetsByTags(tags: string[] | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/findByTags?`; - if (tags !== undefined) { - url += `tags=${serializeQueryParam(tags)}&`; - } - + const url = `${defaults.baseUrl}/pet/findByTags?${defaults.paramsSerializer({'tags': tags, + })}`; + return fetch(url, { method: 'GET', ...$config, @@ -92,7 +89,7 @@ export const petClient = { getPetById(petId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; + const url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; return fetch(url, { method: 'GET', @@ -107,7 +104,7 @@ export const petClient = { updatePet(body: Pet , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet?`; + const url = `${defaults.baseUrl}/pet?`; return fetch(url, { method: 'PUT', @@ -127,14 +124,10 @@ export const petClient = { status: string | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?`; - if (name !== undefined) { - url += `name=${serializeQueryParam(name)}&`; - } - if (status !== undefined) { - url += `status=${serializeQueryParam(status)}&`; - } - + const url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}?${defaults.paramsSerializer({'name': name, + 'status': status, + })}`; + return fetch(url, { method: 'POST', ...$config, @@ -152,11 +145,9 @@ export const petClient = { additionalMetadata: string | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}/uploadImage?`; - if (additionalMetadata !== undefined) { - url += `additionalMetadata=${serializeQueryParam(additionalMetadata)}&`; - } - + const url = `${defaults.baseUrl}/pet/${encodeURIComponent(`${petId}`)}/uploadImage?${defaults.paramsSerializer({'additionalMetadata': additionalMetadata, + })}`; + return fetch(url, { method: 'POST', body: body, @@ -173,7 +164,7 @@ export const storeClient = { deleteOrder(orderId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/store/order/${encodeURIComponent(`${orderId}`)}?`; + const url = `${defaults.baseUrl}/store/order/${encodeURIComponent(`${orderId}`)}?`; return fetch(url, { method: 'DELETE', @@ -186,7 +177,7 @@ export const storeClient = { */ getInventory($config?: RequestInit ): Promise<{ [key: string]: number }> { - let url = `${defaults.baseUrl}/store/inventory?`; + const url = `${defaults.baseUrl}/store/inventory?`; return fetch(url, { method: 'GET', @@ -201,7 +192,7 @@ export const storeClient = { getOrderById(orderId: number , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/store/order/${encodeURIComponent(`${orderId}`)}?`; + const url = `${defaults.baseUrl}/store/order/${encodeURIComponent(`${orderId}`)}?`; return fetch(url, { method: 'GET', @@ -216,7 +207,7 @@ export const storeClient = { placeOrder(body: Order | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/store/order?`; + const url = `${defaults.baseUrl}/store/order?`; return fetch(url, { method: 'POST', @@ -234,7 +225,7 @@ export const userClient = { createUser(body: User | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user?`; + const url = `${defaults.baseUrl}/user?`; return fetch(url, { method: 'POST', @@ -250,7 +241,7 @@ export const userClient = { createUsersWithListInput(body: User[] | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/createWithList?`; + const url = `${defaults.baseUrl}/user/createWithList?`; return fetch(url, { method: 'POST', @@ -266,7 +257,7 @@ export const userClient = { deleteUser(username: string , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; + const url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; return fetch(url, { method: 'DELETE', @@ -281,7 +272,7 @@ export const userClient = { getUserByName(username: string , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; + const url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; return fetch(url, { method: 'GET', @@ -298,14 +289,10 @@ export const userClient = { password: string | null | undefined, $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/login?`; - if (username !== undefined) { - url += `username=${serializeQueryParam(username)}&`; - } - if (password !== undefined) { - url += `password=${serializeQueryParam(password)}&`; - } - + const url = `${defaults.baseUrl}/user/login?${defaults.paramsSerializer({'username': username, + 'password': password, + })}`; + return fetch(url, { method: 'GET', ...$config, @@ -317,7 +304,7 @@ export const userClient = { */ logoutUser($config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/logout?`; + const url = `${defaults.baseUrl}/user/logout?`; return fetch(url, { method: 'GET', @@ -334,7 +321,7 @@ export const userClient = { username: string , $config?: RequestInit ): Promise { - let url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; + const url = `${defaults.baseUrl}/user/${encodeURIComponent(`${username}`)}?`; return fetch(url, { method: 'PUT', @@ -346,13 +333,38 @@ export const userClient = { }; -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return encodeURIComponent(obj.toJSON()); - if (typeof obj !== 'object' || Array.isArray(obj)) return encodeURIComponent(obj); - return Object.keys(obj) - .reduce((a: any, b) => a.push(`${encodeURIComponent(b)}=${encodeURIComponent(obj[b])}`) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } export interface Order { diff --git a/test/snapshots/ng1.ts b/test/snapshots/ng1.ts index fb3bc32..b53de12 100644 --- a/test/snapshots/ng1.ts +++ b/test/snapshots/ng1.ts @@ -9,7 +9,7 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import { IHttpService, IRequestShortcutConfig, IPromise } from 'angular'; +import type { IHttpService, IRequestShortcutConfig, IPromise } from 'angular'; abstract class BaseService { constructor(protected readonly $http: IHttpService, public baseUrl: string) { } diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index bd7031e..32d73ba 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -9,7 +9,7 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import { Observable } from "rxjs"; +import type { Observable } from "rxjs"; import { Injectable, Inject, Optional, InjectionToken } from "@angular/core"; import { HttpClient } from "@angular/common/http"; @@ -83,7 +83,7 @@ export class petService extends BaseService { body: Pet, config?: any ): Observable { - let url = `/pet?`; + const url = `/pet?`; return this.$post( url, @@ -102,7 +102,7 @@ export class petService extends BaseService { petId: number, config?: any ): Observable { - let url = `/pet/${encodeURIComponent(`${petId}`)}?`; + const url = `/pet/${encodeURIComponent(`${petId}`)}?`; return this.$delete( url, @@ -118,11 +118,9 @@ export class petService extends BaseService { status: ("available" | "pending" | "sold") | null | undefined, config?: any ): Observable { - let url = `/pet/findByStatus?`; - if (status !== undefined && status !== null) { - url += `status=${encodeURIComponent(`${status}`)}&`; - } - + const url = `/pet/findByStatus?${paramsSerializer({'status': status, + })}`; + return this.$get( url, config @@ -137,11 +135,9 @@ export class petService extends BaseService { tags: string[] | null | undefined, config?: any ): Observable { - let url = `/pet/findByTags?`; - if (tags !== undefined && tags !== null) { - url += `tags=${encodeURIComponent(`${tags}`)}&`; - } - + const url = `/pet/findByTags?${paramsSerializer({'tags': tags, + })}`; + return this.$get( url, config @@ -156,7 +152,7 @@ export class petService extends BaseService { petId: number, config?: any ): Observable { - let url = `/pet/${encodeURIComponent(`${petId}`)}?`; + const url = `/pet/${encodeURIComponent(`${petId}`)}?`; return this.$get( url, @@ -172,7 +168,7 @@ export class petService extends BaseService { body: Pet, config?: any ): Observable { - let url = `/pet?`; + const url = `/pet?`; return this.$put( url, @@ -193,14 +189,10 @@ export class petService extends BaseService { status: string | null | undefined, config?: any ): Observable { - let url = `/pet/${encodeURIComponent(`${petId}`)}?`; - if (name !== undefined && name !== null) { - url += `name=${encodeURIComponent(`${name}`)}&`; - } - if (status !== undefined && status !== null) { - url += `status=${encodeURIComponent(`${status}`)}&`; - } - + const url = `/pet/${encodeURIComponent(`${petId}`)}?${paramsSerializer({'name': name, + 'status': status, + })}`; + return this.$post( url, null, @@ -220,11 +212,9 @@ export class petService extends BaseService { additionalMetadata: string | null | undefined, config?: any ): Observable { - let url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage?`; - if (additionalMetadata !== undefined && additionalMetadata !== null) { - url += `additionalMetadata=${encodeURIComponent(`${additionalMetadata}`)}&`; - } - + const url = `/pet/${encodeURIComponent(`${petId}`)}/uploadImage?${paramsSerializer({'additionalMetadata': additionalMetadata, + })}`; + return this.$post( url, body, @@ -253,7 +243,7 @@ export class storeService extends BaseService { orderId: number, config?: any ): Observable { - let url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; + const url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; return this.$delete( url, @@ -267,7 +257,7 @@ export class storeService extends BaseService { getInventory( config?: any ): Observable<{ [key: string]: number }> { - let url = `/store/inventory?`; + const url = `/store/inventory?`; return this.$get( url, @@ -283,7 +273,7 @@ export class storeService extends BaseService { orderId: number, config?: any ): Observable { - let url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; + const url = `/store/order/${encodeURIComponent(`${orderId}`)}?`; return this.$get( url, @@ -299,7 +289,7 @@ export class storeService extends BaseService { body: Order | null | undefined, config?: any ): Observable { - let url = `/store/order?`; + const url = `/store/order?`; return this.$post( url, @@ -329,7 +319,7 @@ export class userService extends BaseService { body: User | null | undefined, config?: any ): Observable { - let url = `/user?`; + const url = `/user?`; return this.$post( url, @@ -346,7 +336,7 @@ export class userService extends BaseService { body: User[] | null | undefined, config?: any ): Observable { - let url = `/user/createWithList?`; + const url = `/user/createWithList?`; return this.$post( url, @@ -363,7 +353,7 @@ export class userService extends BaseService { username: string, config?: any ): Observable { - let url = `/user/${encodeURIComponent(`${username}`)}?`; + const url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$delete( url, @@ -379,7 +369,7 @@ export class userService extends BaseService { username: string, config?: any ): Observable { - let url = `/user/${encodeURIComponent(`${username}`)}?`; + const url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$get( url, @@ -397,14 +387,10 @@ export class userService extends BaseService { password: string | null | undefined, config?: any ): Observable { - let url = `/user/login?`; - if (username !== undefined && username !== null) { - url += `username=${encodeURIComponent(`${username}`)}&`; - } - if (password !== undefined && password !== null) { - url += `password=${encodeURIComponent(`${password}`)}&`; - } - + const url = `/user/login?${paramsSerializer({'username': username, + 'password': password, + })}`; + return this.$get( url, config @@ -417,7 +403,7 @@ export class userService extends BaseService { logoutUser( config?: any ): Observable { - let url = `/user/logout?`; + const url = `/user/logout?`; return this.$get( url, @@ -435,7 +421,7 @@ export class userService extends BaseService { username: string, config?: any ): Observable { - let url = `/user/${encodeURIComponent(`${username}`)}?`; + const url = `/user/${encodeURIComponent(`${username}`)}?`; return this.$put( url, @@ -446,6 +432,41 @@ export class userService extends BaseService { } + +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); +} + export interface Order { id?: number; petId?: number; diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 99b5624..a5d8154 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -9,11 +9,12 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { AxiosPromise, AxiosRequestConfig } from "axios"; +import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; import useSWR, { SWRConfiguration, Key } from 'swr'; export const axios = Axios.create({ baseURL: '', + paramsSerializer: (params: any) => paramsSerializer(params), }); interface SwrConfig extends SWRConfiguration { @@ -72,7 +73,7 @@ export const petClient = { url: url, method: 'GET', params: { - 'status': serializeQueryParam(status), + 'status': status, }, ...$config, }); @@ -90,7 +91,7 @@ export const petClient = { url: url, method: 'GET', params: { - 'tags': serializeQueryParam(tags), + 'tags': tags, }, ...$config, }); @@ -143,8 +144,8 @@ export const petClient = { url: url, method: 'POST', params: { - 'name': serializeQueryParam(name), - 'status': serializeQueryParam(status), + 'name': name, + 'status': status, }, ...$config, }); @@ -167,7 +168,7 @@ export const petClient = { method: 'POST', data: body, params: { - 'additionalMetadata': serializeQueryParam(additionalMetadata), + 'additionalMetadata': additionalMetadata, }, ...$config, }); @@ -195,7 +196,7 @@ export function usepetfindPetsByStatus( status: ("available" | "pending" | "sol url: url, method: 'GET', params: { - 'status': serializeQueryParam(status), + 'status': status, }, ...$axiosConf}) .then((resp) => resp.data), @@ -229,7 +230,7 @@ export function usepetfindPetsByTags( tags: string[] | null | undefined, url: url, method: 'GET', params: { - 'tags': serializeQueryParam(tags), + 'tags': tags, }, ...$axiosConf}) .then((resp) => resp.data), @@ -461,8 +462,8 @@ const { data, error, mutate } = useSWR( url: url, method: 'GET', params: { - 'username': serializeQueryParam(username), - 'password': serializeQueryParam(password), + 'username': username, + 'password': password, }, ...$config, }); @@ -554,8 +555,8 @@ export function useuserloginUser( username: string | null | undefined, url: url, method: 'GET', params: { - 'username': serializeQueryParam(username), - 'password': serializeQueryParam(password), + 'username': username, + 'password': password, }, ...$axiosConf}) .then((resp) => resp.data), @@ -595,14 +596,40 @@ const { data, error, mutate } = useSWR( } -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a: any, b) => a.push(b + '=' + obj[b]) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } + export interface Order { id?: number; petId?: number; diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 24109cb..33b0931 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -13,6 +13,7 @@ import xior, { type XiorResponse, type XiorRequestConfig } from "xior"; export const http = xior.create({ baseURL: '', + paramsSerializer: (params) => paramsSerializer(params), }); export const petClient = { @@ -64,7 +65,7 @@ export const petClient = { url: url, method: 'GET', params: { - 'status': serializeQueryParam(status), + 'status': status, }, ...$config, }); @@ -82,7 +83,7 @@ export const petClient = { url: url, method: 'GET', params: { - 'tags': serializeQueryParam(tags), + 'tags': tags, }, ...$config, }); @@ -135,8 +136,8 @@ export const petClient = { url: url, method: 'POST', params: { - 'name': serializeQueryParam(name), - 'status': serializeQueryParam(status), + 'name': name, + 'status': status, }, ...$config, }); @@ -159,7 +160,7 @@ export const petClient = { method: 'POST', data: body, params: { - 'additionalMetadata': serializeQueryParam(additionalMetadata), + 'additionalMetadata': additionalMetadata, }, ...$config, }); @@ -306,8 +307,8 @@ export const userClient = { url: url, method: 'GET', params: { - 'username': serializeQueryParam(username), - 'password': serializeQueryParam(password), + 'username': username, + 'password': password, }, ...$config, }); @@ -347,13 +348,38 @@ export const userClient = { }; -function serializeQueryParam(obj: any) { - if (obj === null || obj === undefined) return ''; - if (obj instanceof Date) return obj.toJSON(); - if (typeof obj !== 'object' || Array.isArray(obj)) return obj; - return Object.keys(obj) - .reduce((a: any, b) => a.push(`${b}=${obj[b]}`) && a, []) - .join('&'); +function paramsSerializer(params: T, parentKey: string | null = null): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const encodeValue = (value: any) => + encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + const value = (params as any)[key]; + if (value !== undefined) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + + if (Array.isArray(value)) { + for (const element of value) { + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); + } + } else if (value instanceof Date && !Number.isNaN(value)) { + // If the value is a Date, convert it to ISO format + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } else if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = paramsSerializer(value, fullKey); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + } + } + } + } + + return encodedParams.join('&'); } export interface Order { From 0e545fe1fe94d1ba628643397917e32793698b21 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Mon, 8 Jul 2024 21:17:56 +0200 Subject: [PATCH 20/27] docs: add Kiota to comparison --- README.md | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bc8981f..2f95b2d 100644 --- a/README.md +++ b/README.md @@ -223,17 +223,41 @@ Server is not necessary to use Swaggie. Swaggie cares only about the JSON/yaml f If you are familiar with the client-code generators for the Swagger / OpenAPI standards then you might wonder why `swaggie` is better than existing tools. Currently the most popular alternative is an open-source `NSwag`. -Quick comparison table: - -| swaggie | NSwag | Hey API | -| ------------------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | -| Written in node.js + TypeScript | Written in .NET | Written in TypeScript | -| Fast | Slow | Fast | -| ![swaggie size](https://packagephobia.now.sh/badge?p=swaggie) | ![nswag size](https://packagephobia.now.sh/badge?p=nswag) | ![nswag size](https://packagephobia.now.sh/badge?p=%40hey-api%2Fopenapi-ts) | -| Easy to contribute to | Contributing hard | Does not allow custom templates, so change is hard | -| Lightweight | Complicated templates | Generates a lot of code and multiple files | -| Flexible, suits well in the existing apps | Flexible, suits well in the existing apps | Enforces usage of other tools and architecture | -| Generates REST clients and all models | Many more features (but mostly for .NET apps) | No flexibility, other clients are discouraged from use | +Quick comparison: + +### Swaggie + +- Fast and small ![swaggie size](https://packagephobia.now.sh/badge?p=swaggie) +- Lightweight and easy to start +- Easy to contribute to, custom templates +- Flexible, suits well in the existing apps +- Generates REST clients and all models +- Supports different templates (like `axios`, `fetch`, `xior`, `swr-axios`, `ng1`, `ng2`) +- Written in TypeScript +- Generates only one file with everything you need inside + +### [NSwag](https://github.com/RicoSuter/NSwag) + +- Slow and big ![nswag size](https://packagephobia.now.sh/badge?p=nswag) +- Complicated templates, not easy to contribute to +- Enforces usage of other tools and architecture +- Generates more boilerplate code +- Written in .NET, require .NET to execute, although published to npm as well +- Many more features (but mostly for .NET apps), client generation is just a part of it + +### [Hey API](https://heyapi.vercel.app) + +- Fast and small ![nswag size](https://packagephobia.now.sh/badge?p=%40hey-api%2Fopenapi-ts) +- No flexibility, other clients are discouraged from use +- Generates a lot of code and multiple files +- Written in TypeScript + +### [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/) + +- A lot of boilerplate code and many files +- Written in .NET, requires .NET to execute, published to NuGet +- Not flexible at all - you need to use their architecture in your code +- Looks like an enterprise solution with many configuration options ## Notes From 73a4769d8be5399d2abb7d58e452aecba7a6dbb1 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 10 Jul 2024 09:47:03 +0200 Subject: [PATCH 21/27] impr: more robust query parameter serialization that offers more configuration options --- .gitignore | 1 + README.md | 4 +- package.json | 2 +- src/utils/encodeParams.spec.ts | 205 +++++++++++++++++++++++++++++ src/utils/paramSerializer.spec.ts | 70 ---------- templates/axios/barrel.ejs | 53 +++++--- templates/axios/baseClient.ejs | 8 +- templates/fetch/barrel.ejs | 53 +++++--- templates/fetch/baseClient.ejs | 6 +- templates/ng2/barrel.ejs | 60 ++++++--- templates/swr-axios/barrel.ejs | 53 +++++--- templates/swr-axios/baseClient.ejs | 10 +- templates/xior/barrel.ejs | 35 ----- templates/xior/baseClient.ejs | 8 +- test/snapshots/axios.ts | 63 ++++++--- test/snapshots/fetch.ts | 61 ++++++--- test/snapshots/ng2.ts | 62 ++++++--- test/snapshots/swr-axios.ts | 65 ++++++--- test/snapshots/xior.ts | 43 +----- 19 files changed, 579 insertions(+), 283 deletions(-) create mode 100644 src/utils/encodeParams.spec.ts delete mode 100644 src/utils/paramSerializer.spec.ts diff --git a/.gitignore b/.gitignore index 2bc1585..521ddc7 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ dist/ coverage/ test/api*.json .tmp/ +.nyc_output/ diff --git a/README.md b/README.md index 2f95b2d..5bb7831 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![npm latest version](https://img.shields.io/npm/v/swaggie) ![NodeCI](https://github.com/yhnavein/swaggie/workflows/NodeCI/badge.svg) -![Snyk Vulnerabilities for GitHub Repo](https://img.shields.io/snyk/vulnerabilities/github/yhnavein/swaggie.svg) +![Test Coverage](https://img.shields.io/badge/test_coverage-98%25-brightgreen) ![npm downloads](https://img.shields.io/npm/dw/swaggie.svg) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/swaggie.svg) ![npm install size](https://packagephobia.now.sh/badge?p=swaggie) @@ -268,7 +268,7 @@ Quick comparison: | Spec formats: `JSON`, `YAML` | Very complex query params | | Extensions: `x-position`, `x-name`, `x-enumNames`, `x-enum-varnames` | Multiple response types (one will be used) | | Content types: `JSON`, `text`, `multipart/form-data` | Multiple request types (one will be used) | -| Content types: `application/x-www-form-urlencoded`, `application/octet-stream` | | +| Content types: `application/x-www-form-urlencoded`, `application/octet-stream` | References to other spec files | | Different types of enum definitions (+ OpenAPI 3.1 support for enums) | | | Paths inheritance, comments (descriptions) | | | Getting documents from remote locations or as path reference (local file) | | diff --git a/package.json b/package.json index e7e8fad..fc7667f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.0", + "version": "1.0.0-beta.1", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", diff --git a/src/utils/encodeParams.spec.ts b/src/utils/encodeParams.spec.ts new file mode 100644 index 0000000..3c7a411 --- /dev/null +++ b/src/utils/encodeParams.spec.ts @@ -0,0 +1,205 @@ +import { expect } from 'chai'; + +/** This file tests a code that will be generated and is hardcoded into the templates */ + +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { + if (params === undefined || params === null) return ''; + const encodedParams: string[] = []; + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; + + for (const key in params) { + if (Object.prototype.hasOwnProperty.call(params, key)) { + let value = (params as any)[key]; + if (value !== undefined) { + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); + + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { + // If the value is an object or array, recursively encode its contents + const result = encodeParams(value, encodedKey, options); + if (result !== '') encodedParams.push(result); + } else { + // Otherwise, encode the key-value pair + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); + } + } + } + } + + return encodedParams.join('&'); +} + +const date = new Date('2020-04-16T00:00:00.000Z'); + +describe('encodeParams', () => { + type Opts = { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + }; + + type Case = { input: any; exp: string }; + type TestCase = { opts: Opts; cases: Case[] }; + + const testCases: TestCase[] = [ + { + opts: {}, + cases: [ + { input: '', exp: '' }, + { input: null, exp: '' }, + { input: undefined, exp: '' }, + { input: {}, exp: '' }, + ], + }, + { + opts: { allowDots: true, arrayFormat: 'repeat' }, + cases: [ + { input: { a: 1, b: 'test' }, exp: 'a=1&b=test' }, + { input: { a: 1, b: [1, 2, 3, 'test'] }, exp: 'a=1&b=1&b=2&b=3&b=test' }, + { input: { a: { b: { c: 1, d: 'test' } } }, exp: 'a.b.c=1&a.b.d=test' }, + { + input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, + exp: 'a.b.d=test&a.b.f=', + }, + { input: { a: [1, 2, 3] }, exp: 'a=1&a=2&a=3' }, + { input: { a: date }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { input: { a: [date] }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { + input: { a: [date, date] }, + exp: encodeUri('a=2020-04-16T00:00:00.000Z&a=2020-04-16T00:00:00.000Z'), + }, + ], + }, + { + opts: { allowDots: true, arrayFormat: 'brackets' }, + cases: [ + { input: { a: 1, b: 'test' }, exp: 'a=1&b=test' }, + { + input: { a: 1, b: [1, 2, 3, 'test'] }, + exp: encodeUri('a=1&b[]=1&b[]=2&b[]=3&b[]=test'), + }, + { input: { a: { b: { c: 1, d: 'test' } } }, exp: encodeUri('a.b.c=1&a.b.d=test') }, + { + input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, + exp: encodeUri('a.b.d=test&a.b.f='), + }, + { input: { a: [1, 2, 3] }, exp: encodeUri('a[]=1&a[]=2&a[]=3') }, + { input: { a: date }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { input: { a: [date] }, exp: encodeUri('a[]=2020-04-16T00:00:00.000Z') }, + { + input: { a: [date, date] }, + exp: encodeUri('a[]=2020-04-16T00:00:00.000Z&a[]=2020-04-16T00:00:00.000Z'), + }, + ], + }, + { + opts: { allowDots: true, arrayFormat: 'indices' }, + cases: [ + { input: { a: 1, b: 'test' }, exp: 'a=1&b=test' }, + { + input: { a: 1, b: [1, 2, 3, 'test'] }, + exp: encodeUri('a=1&b[0]=1&b[1]=2&b[2]=3&b[3]=test'), + }, + { input: { a: { b: { c: 1, d: 'test' } } }, exp: encodeUri('a.b.c=1&a.b.d=test') }, + { + input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, + exp: encodeUri('a.b.d=test&a.b.f='), + }, + { input: { a: [1, 2, 3] }, exp: encodeUri('a[0]=1&a[1]=2&a[2]=3') }, + { input: { a: date }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { input: { a: [date] }, exp: encodeUri('a[0]=2020-04-16T00:00:00.000Z') }, + { + input: { a: [date, date] }, + exp: encodeUri('a[0]=2020-04-16T00:00:00.000Z&a[1]=2020-04-16T00:00:00.000Z'), + }, + ], + }, + { + opts: { allowDots: false, arrayFormat: 'repeat' }, + cases: [ + { input: { a: 1, b: 'test' }, exp: 'a=1&b=test' }, + { input: { a: 1, b: [1, 2, 3, 'test'] }, exp: 'a=1&b=1&b=2&b=3&b=test' }, + { input: { a: { b: { c: 1, d: 'test' } } }, exp: encodeUri('a[b][c]=1&a[b][d]=test') }, + { + input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, + exp: encodeUri('a[b][d]=test&a[b][f]='), + }, + { input: { a: [1, 2, 3] }, exp: 'a=1&a=2&a=3' }, + { input: { a: date }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { input: { a: [date] }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { + input: { a: [date, date] }, + exp: encodeUri('a=2020-04-16T00:00:00.000Z&a=2020-04-16T00:00:00.000Z'), + }, + ], + }, + { + opts: { allowDots: false, arrayFormat: 'indices' }, + cases: [ + { input: { a: 1, b: 'test' }, exp: 'a=1&b=test' }, + { + input: { a: 1, b: [1, 2, 3, 'test'] }, + exp: encodeUri('a=1&b[0]=1&b[1]=2&b[2]=3&b[3]=test'), + }, + { input: { a: { b: { c: 1, d: 'test' } } }, exp: encodeUri('a[b][c]=1&a[b][d]=test') }, + { + input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, + exp: encodeUri('a[b][d]=test&a[b][f]='), + }, + { input: { a: [1, 2, 3] }, exp: encodeUri('a[0]=1&a[1]=2&a[2]=3') }, + { input: { a: date }, exp: encodeUri('a=2020-04-16T00:00:00.000Z') }, + { input: { a: [date] }, exp: encodeUri('a[0]=2020-04-16T00:00:00.000Z') }, + { + input: { a: [date, date] }, + exp: encodeUri('a[0]=2020-04-16T00:00:00.000Z&a[1]=2020-04-16T00:00:00.000Z'), + }, + ], + }, + ]; + + for (const el of testCases) { + describe(`with options: ${JSON.stringify(el.opts)}`, () => { + for (const { input, exp } of el.cases) { + it(`should handle ${JSON.stringify(input)}`, () => { + const res = encodeParams(input, null, el.opts); + + expect(res).to.be.equal(exp); + }); + } + }); + } +}); + +function encodeUri(query: string): string { + return encodeURI(query).replaceAll(':', '%3A'); +} diff --git a/src/utils/paramSerializer.spec.ts b/src/utils/paramSerializer.spec.ts deleted file mode 100644 index 5e0ca40..0000000 --- a/src/utils/paramSerializer.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect } from 'chai'; - -/** This file tests a code that will be generated and is hardcoded into the templates */ - -function paramsSerializer(params: T, parentKey: string | null = null): string { - if (params === undefined || params === null) return ''; - const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); - - for (const key in params) { - if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; - if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { - // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); - if (result !== '') encodedParams.push(result); - } else { - // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } - } - } - } - - return encodedParams.join('&'); -} - -const date = new Date('2020-04-16T00:00:00.000Z'); - -describe('paramsSerializer', () => { - const testCases = [ - { input: '', expected: '' }, - { input: null, expected: '' }, - { input: undefined, expected: '' }, - { input: {}, expected: '' }, - { input: { a: 1, b: 'test' }, expected: 'a=1&b=test' }, - { input: { a: 1, b: [1, 2, 3, 'test'] }, expected: 'a=1&b=1&b=2&b=3&b=test' }, - { input: { a: { b: { c: 1, d: 'test' } } }, expected: 'a.b.c=1&a.b.d=test' }, - { - input: { a: { b: { c: undefined, d: 'test', e: null, f: '' } } }, - expected: 'a.b.d=test&a.b.f=', - }, - { input: { a: [1, 2, 3] }, expected: 'a=1&a=2&a=3' }, - { input: { a: date }, expected: 'a=2020-04-16T00%3A00%3A00.000Z' }, - { input: { a: [date] }, expected: 'a=2020-04-16T00%3A00%3A00.000Z' }, - { - input: { a: [date, date] }, - expected: 'a=2020-04-16T00%3A00%3A00.000Z&a=2020-04-16T00%3A00%3A00.000Z', - }, - ]; - - for (const el of testCases) { - it(`should handle ${JSON.stringify(el.input)}`, () => { - const res = paramsSerializer(el.input); - - expect(res).to.be.equal(el.expected); - }); - } -}); diff --git a/templates/axios/barrel.ejs b/templates/axios/barrel.ejs index 1d1fdf6..83f397e 100644 --- a/templates/axios/barrel.ejs +++ b/templates/axios/barrel.ejs @@ -1,30 +1,53 @@ -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } diff --git a/templates/axios/baseClient.ejs b/templates/axios/baseClient.ejs index e9c7539..0c92c54 100644 --- a/templates/axios/baseClient.ejs +++ b/templates/axios/baseClient.ejs @@ -9,10 +9,14 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; +import Axios, { type AxiosPromise, type AxiosRequestConfig } from "axios"; export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' %>', - paramsSerializer: (params: any) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); diff --git a/templates/fetch/barrel.ejs b/templates/fetch/barrel.ejs index 1d1fdf6..83f397e 100644 --- a/templates/fetch/barrel.ejs +++ b/templates/fetch/barrel.ejs @@ -1,30 +1,53 @@ -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } diff --git a/templates/fetch/baseClient.ejs b/templates/fetch/baseClient.ejs index 9943872..1e2bf88 100644 --- a/templates/fetch/baseClient.ejs +++ b/templates/fetch/baseClient.ejs @@ -11,6 +11,10 @@ export const defaults = { baseUrl: '<%= it.baseUrl || '' %>', - paramsSerializer: (params: any) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }; diff --git a/templates/ng2/barrel.ejs b/templates/ng2/barrel.ejs index 1d1fdf6..5d28162 100644 --- a/templates/ng2/barrel.ejs +++ b/templates/ng2/barrel.ejs @@ -1,30 +1,53 @@ -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } @@ -33,3 +56,10 @@ function paramsSerializer(params: T, parentKey: string | null = null): return encodedParams.join('&'); } +function paramsSerializer(params: any) { + return encodeParams(params, true, null, { + allowDots: true, + arrayFormat: 'repeat', + }); +} + diff --git a/templates/swr-axios/barrel.ejs b/templates/swr-axios/barrel.ejs index 1d1fdf6..83f397e 100644 --- a/templates/swr-axios/barrel.ejs +++ b/templates/swr-axios/barrel.ejs @@ -1,30 +1,53 @@ -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } diff --git a/templates/swr-axios/baseClient.ejs b/templates/swr-axios/baseClient.ejs index 9c5be83..542255e 100644 --- a/templates/swr-axios/baseClient.ejs +++ b/templates/swr-axios/baseClient.ejs @@ -9,12 +9,16 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; -import useSWR, { SWRConfiguration, Key } from 'swr'; +import Axios, { type AxiosPromise, type AxiosRequestConfig } from "axios"; +import useSWR, { type SWRConfiguration, type Key } from 'swr'; export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' -%>', - paramsSerializer: (params: any) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); interface SwrConfig extends SWRConfiguration { diff --git a/templates/xior/barrel.ejs b/templates/xior/barrel.ejs index 1d1fdf6..e69de29 100644 --- a/templates/xior/barrel.ejs +++ b/templates/xior/barrel.ejs @@ -1,35 +0,0 @@ - -function paramsSerializer(params: T, parentKey: string | null = null): string { - if (params === undefined || params === null) return ''; - const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); - - for (const key in params) { - if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; - if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { - // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); - if (result !== '') encodedParams.push(result); - } else { - // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } - } - } - } - - return encodedParams.join('&'); -} - diff --git a/templates/xior/baseClient.ejs b/templates/xior/baseClient.ejs index d8ead04..0185c44 100644 --- a/templates/xior/baseClient.ejs +++ b/templates/xior/baseClient.ejs @@ -9,10 +9,14 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import xior, { type XiorResponse, type XiorRequestConfig } from "xior"; +import xior, { type XiorResponse, type XiorRequestConfig, encodeParams } from "xior"; export const http = xior.create({ baseURL: '<%= it.baseUrl || '' %>', - paramsSerializer: (params) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, true, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index c376cb4..d7228fa 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -9,11 +9,15 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; +import Axios, { type AxiosPromise, type AxiosRequestConfig } from "axios"; export const axios = Axios.create({ baseURL: '', - paramsSerializer: (params: any) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); export const petClient = { @@ -348,32 +352,55 @@ export const userClient = { }; -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); + + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index c57086e..5ce0b50 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -11,7 +11,11 @@ export const defaults = { baseUrl: '', - paramsSerializer: (params: any) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }; export const petClient = { @@ -333,32 +337,55 @@ export const userClient = { }; -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); + + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index 32d73ba..2f32440 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -433,32 +433,55 @@ export class userService extends BaseService { } -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); + + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } @@ -467,6 +490,13 @@ function paramsSerializer(params: T, parentKey: string | null = null): return encodedParams.join('&'); } +function paramsSerializer(params: any) { + return encodeParams(params, true, null, { + allowDots: true, + arrayFormat: 'repeat', + }); +} + export interface Order { id?: number; petId?: number; diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index a5d8154..3c54c3a 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -9,12 +9,16 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import Axios, { type AxiosPromise, AxiosRequestConfig } from "axios"; -import useSWR, { SWRConfiguration, Key } from 'swr'; +import Axios, { type AxiosPromise, type AxiosRequestConfig } from "axios"; +import useSWR, { type SWRConfiguration, type Key } from 'swr'; export const axios = Axios.create({ baseURL: '', - paramsSerializer: (params: any) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); interface SwrConfig extends SWRConfiguration { @@ -596,32 +600,55 @@ const { data, error, mutate } = useSWR( } -function paramsSerializer(params: T, parentKey: string | null = null): string { +/** + * Serializes a params object into a query string that is compatible with different REST APIs. + * Implementation from: https://github.com/suhaotian/xior/blob/main/src/utils.ts + * Kudos to @suhaotian for the original implementation + */ +function encodeParams( + params: T, + parentKey: string | null = null, + options?: { + allowDots?: boolean; + serializeDate?: (value: Date) => string; + arrayFormat?: 'indices' | 'repeat' | 'brackets'; + } +): string { if (params === undefined || params === null) return ''; const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); + const paramsIsArray = Array.isArray(params); + const { arrayFormat, allowDots, serializeDate } = options || {}; + + const getKey = (key: string) => { + if (allowDots && !paramsIsArray) return `.${key}`; + if (paramsIsArray) { + if (arrayFormat === 'brackets') { + return '[]'; + } + if (arrayFormat === 'repeat') { + return ''; + } + } + return `[${key}]`; + }; for (const key in params) { if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; + let value = (params as any)[key]; if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { + const encodedKey = parentKey ? `${parentKey}${getKey(key)}` : (key as string); + + // biome-ignore lint/suspicious/noGlobalIsNan: + if (!isNaN(value) && value instanceof Date) { + value = serializeDate ? serializeDate(value) : value.toISOString(); + } + if (typeof value === 'object') { // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); + const result = encodeParams(value, encodedKey, options); if (result !== '') encodedParams.push(result); } else { // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); + encodedParams.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(value)}`); } } } diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index 33b0931..cd08c9c 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -9,11 +9,15 @@ // ReSharper disable InconsistentNaming // deno-lint-ignore-file -import xior, { type XiorResponse, type XiorRequestConfig } from "xior"; +import xior, { type XiorResponse, type XiorRequestConfig, encodeParams } from "xior"; export const http = xior.create({ baseURL: '', - paramsSerializer: (params) => paramsSerializer(params), + paramsSerializer: (params) => + encodeParams(params, true, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); export const petClient = { @@ -347,41 +351,6 @@ export const userClient = { }; - -function paramsSerializer(params: T, parentKey: string | null = null): string { - if (params === undefined || params === null) return ''; - const encodedParams: string[] = []; - const encodeValue = (value: any) => - encodeURIComponent(value instanceof Date && !Number.isNaN(value) ? value.toISOString() : value); - - for (const key in params) { - if (Object.prototype.hasOwnProperty.call(params, key)) { - const value = (params as any)[key]; - if (value !== undefined) { - const fullKey = parentKey ? `${parentKey}.${key}` : key; - - if (Array.isArray(value)) { - for (const element of value) { - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(element)}`); - } - } else if (value instanceof Date && !Number.isNaN(value)) { - // If the value is a Date, convert it to ISO format - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } else if (typeof value === 'object') { - // If the value is an object or array, recursively encode its contents - const result = paramsSerializer(value, fullKey); - if (result !== '') encodedParams.push(result); - } else { - // Otherwise, encode the key-value pair - encodedParams.push(`${encodeURIComponent(fullKey)}=${encodeValue(value)}`); - } - } - } - } - - return encodedParams.join('&'); -} - export interface Order { id?: number; petId?: number; From d7c125666976aa072739170265bc5c1b52b0af5c Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 10 Jul 2024 12:17:52 +0200 Subject: [PATCH 22/27] feat: allow query param serialization config --- README.md | 108 +++++++++++++++++++---------- api.config.json | 14 ++-- package.json | 2 +- schema.json | 24 +++++++ src/cli.ts | 28 ++++++-- src/gen/genOperations.ts | 8 ++- src/index.spec.ts | 32 +++++++-- src/index.ts | 35 +++++++++- src/types.ts | 12 +++- src/utils/test.utils.ts | 4 ++ templates/axios/baseClient.ejs | 4 +- templates/fetch/baseClient.ejs | 4 +- templates/ng2/barrel.ejs | 7 -- templates/ng2/baseClient.ejs | 7 ++ templates/swr-axios/baseClient.ejs | 4 +- templates/xior/baseClient.ejs | 4 +- test/ci-test.config.json | 6 +- test/sample-config.json | 6 +- test/snapshots.spec.ts | 4 ++ test/snapshots/ng2.ts | 14 ++-- 20 files changed, 242 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 5bb7831..730d524 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,17 @@ Usage: swaggie [options] Options: - -h, --help output usage information - -V, --version output the version number - -c, --config The path to the configuration JSON file. You can do all the set up there instead of parameters in the CLI - -s, --src The url or path to the Open API spec file - -t, --template Template used forgenerating API client. Default: "axios" - -o, --out The path to the file where the API would be generated - -b, --baseUrl Base URL that will be used as a default value in the clients. Default: "" - --preferAny Use "any" type instead of "unknown". Default: false - --servicePrefix Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions. Default: '' + -V, --version output the version number + -c, --config The path to the configuration JSON file. You can do all the set up there instead of parameters in the CLI + -s, --src The url or path to the Open API spec file + -o, --out The path to the file where the API would be generated. Use stdout if left empty + -b, --baseUrl Base URL that will be used as a default value in the clients (default: "") + -t, --template Template used forgenerating API client. Default: "axios" (default: "axios") + --preferAny Use "any" type instead of "unknown" (default: false) + --servicePrefix Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions (default: "") + --allowDots Determines if dots should be used for serialization object properties + --arrayFormat Determines how arrays should be serialized (choices: "indices", "repeat", "brackets") + -h, --help display help for command ``` Sample CLI usage using Swagger's Pet Store: @@ -89,7 +91,11 @@ Sample configuration looks like this: "baseUrl": "/api", "preferAny": true, "servicePrefix": "", - "dateFormat": "Date" // "string" | "Date" + "dateFormat": "Date", // "string" | "Date" + "queryParamsSerialization": { + "arrayFormat": "repeat", // "repeat" | "brackets" | "indices" + "allowDots": true + } } ``` @@ -112,36 +118,44 @@ If you want to use your own template, you can use the path to your template for swaggie -s https://petstore3.swagger.io/api/v3/openapi.json -o ./client/petstore --template ./my-swaggie-template/ ``` -### Code +## Usage – Integrating into your project -```javascript -const swaggie = require('swaggie'); -swaggie - .genCode({ - src: 'https://petstore3.swagger.io/api/v3/openapi.json', - out: './api/petstore.ts', - }) - .then(complete, error); +Let's assume that you have a [PetStore API](http://petstore.swagger.io/) as your REST API and you are developing a client app written in TypeScript that will consume this API. -function complete(spec) { - console.info('Service generation complete'); -} +Instead of writing any code by hand for fetching particular resources, we will let Swaggie do it for us. -function error(e) { - console.error(e.toString()); -} -``` +### Query Parameters Serialization -## Usage – Integrating into your project +When it comes to use of query parameters then you might need to adjust the way these parameters will be serialized, as backend server you are using expects them to be in a specific format. Thankfully in Swaggie you can specify how they should be handled. If you won't provide any configuration, then Swaggie will use the defaults values expected in the ASP.NET Core world. -Let's assume that you have a [PetStore API](http://petstore.swagger.io/) as your REST API and you are developing a client app written in TypeScript that will consume this API. +For your convenience there are few config examples to achieve different serialization formats for an object `{ "a": { "b": 1 }, "c": [2, 3] }`: -Instead of writing any code by hand for fetching particular resources, we will let Swaggie do it for us. +| Expected Format | allowDots | arrayFormat | +| ----------------------- | --------- | ----------- | +| `?a.b=1&c=2&c=3` | `true` | `repeat` | +| `?a.b=1&c[]=2&c[]=3` | `true` | `brackets` | +| `?a.b=1&c[0]=2&c[1]=3` | `true` | `indices` | +| `?a[b]=1&c=2&c=3` | `false` | `repeat` | +| `?a[b]=1&c[]=2&c[]=3` | `false` | `brackets` | +| `?a[b]=1&c[0]=2&c[1]=3` | `false` | `indices` | + +Once you know what your backend expects, you can adjust the configuration file accordingly: (below are default values) + +```json +{ + "queryParamsSerialization": { + "arrayFormat": "repeat", + "allowDots": true + } +} +``` + +### Code Quality > Please note that it's **recommended** to pipe Swaggie command to some prettifier like `prettier`, `biome` or `dprint` to make the generated code look not only nice, but also persistent. > Because Swaggie relies on a templating engine, whitespaces are generally a mess, so they may change between versions. -### Suggested prettiers +**Suggested prettiers** [prettier](https://prettier.io/) - the most popular one @@ -171,6 +185,11 @@ swaggie -s https://petstore3.swagger.io/api/v3/openapi.json -o ./api/petstore.ts import Axios, { AxiosPromise } from 'axios'; const axios = Axios.create({ baseURL: '/api', + paramsSerializer: (params) => + encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }), }); /** [...] **/ @@ -178,12 +197,9 @@ const axios = Axios.create({ export const petClient = { /** * @param petId - * @return Success */ getPetById(petId: number): AxiosPromise { - let url = '/pet/{petId}'; - - url = url.replace('{petId}', encodeURIComponent('' + petId)); + let url = `/pet/${encodeURIComponent(`${petId}`)}`; return axios.request({ url: url, @@ -221,9 +237,7 @@ Server is not necessary to use Swaggie. Swaggie cares only about the JSON/yaml f ## Competitors -If you are familiar with the client-code generators for the Swagger / OpenAPI standards then you might wonder why `swaggie` is better than existing tools. Currently the most popular alternative is an open-source `NSwag`. - -Quick comparison: +If you are familiar with the client-code generators for the Swagger / OpenAPI standards then you might wonder why `swaggie` is better than existing tools. I compiled a quick comparison with other tools below: ### Swaggie @@ -259,6 +273,26 @@ Quick comparison: - Not flexible at all - you need to use their architecture in your code - Looks like an enterprise solution with many configuration options +## Using Swaggie programmatically + +```javascript +const swaggie = require('swaggie'); +swaggie + .genCode({ + src: 'https://petstore3.swagger.io/api/v3/openapi.json', + out: './api/petstore.ts', + }) + .then(complete, error); + +function complete(spec) { + console.info('Service generation complete'); +} + +function error(e) { + console.error(e.toString()); +} +``` + ## Notes | Supported | Not supported | diff --git a/api.config.json b/api.config.json index 9c8adf3..60a360a 100644 --- a/api.config.json +++ b/api.config.json @@ -1,8 +1,12 @@ { - "$schema": "https://raw.githubusercontent.com/yhnavein/swaggie/master/schema.json", - "out": "./.tmp/swagger-test/petstore.ts", - "src": "https://petstore.swagger.io/v2/swagger.json", - "template": "ng2", + "$schema": "./schema.json", + "out": "./.tmp/fetch-petstore.ts", + "src": "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json", + "template": "fetch", "preferAny": true, - "dateFormat": "string" + "dateFormat": "string", + "queryParamsSerialization": { + "arrayFormat": "indices", + "allowDots": false + } } diff --git a/package.json b/package.json index fc7667f..d9aba7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", diff --git a/schema.json b/schema.json index 04fc21a..e22be78 100644 --- a/schema.json +++ b/schema.json @@ -3,6 +3,27 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Swaggie Settings Schema", "definitions": { + "QueryParamsSerialization": { + "additionalProperties": true, + "description": "Settings for query parameters serialization", + "properties": { + "allowDots": { + "description": "Determines if dots should be used for serialization object properties. Otherwise brackets will be used", + "type": "boolean", + "default": true + }, + "arrayFormat": { + "description": "Determines how arrays should be serialized", + "enum": [ + "indices", + "repeat", + "brackets" + ], + "type": "string", + "default": "repeat" + } + } + }, "Globals": { "additionalProperties": true, "description": "Main settings of the application", @@ -51,6 +72,9 @@ "string" ], "type": "string" + }, + "queryParamsSerialization": { + "$ref": "#/definitions/QueryParamsSerialization" } }, "required": [ diff --git a/src/cli.ts b/src/cli.ts index 586178b..2e10c81 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,16 @@ #!/usr/bin/env node import { bold, cyan, red } from 'nanocolors'; -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { type CodeGenResult, runCodeGenerator } from './index'; import type { FullAppOptions } from './types'; +const arrayFormatOption = new Option( + '--arrayFormat ', + 'Determines how arrays should be serialized' +).choices(['indices', 'repeat', 'brackets']); + const program = new Command(); program .version(require('../package.json').version) @@ -28,14 +33,25 @@ program ) .option( '-b, --baseUrl ', - 'Base URL that will be used as a default value in the clients. Default: ""' + 'Base URL that will be used as a default value in the clients', + '' + ) + .option( + '-t, --template ', + 'Template used forgenerating API client. Default: "axios"', + 'axios' ) - .option('-t, --template ', 'Template used forgenerating API client. Default: "axios"') - .option('--preferAny', 'Use "any" type instead of "unknown". Default: false') + .option('--preferAny', 'Use "any" type instead of "unknown"', false) .option( '--servicePrefix ', - 'Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions. Default: ""' - ); + 'Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions', + '' + ) + .option( + '--allowDots ', + 'Determines if dots should be used for serialization object properties' + ) + .addOption(arrayFormatOption); program.parse(process.argv); diff --git a/src/gen/genOperations.ts b/src/gen/genOperations.ts index f508596..ef0c402 100644 --- a/src/gen/genOperations.ts +++ b/src/gen/genOperations.ts @@ -24,19 +24,21 @@ export default async function generateOperations( ): Promise { const operations = getOperations(spec); const groups = groupOperationsByGroupName(operations); + const servicePrefix = options.servicePrefix ?? ''; let result = renderFile('baseClient.ejs', { - servicePrefix: options.servicePrefix || '', + servicePrefix, baseUrl: options.baseUrl, + ...options.queryParamsSerialization, }) || ''; for (const name in groups) { const group = groups[name]; - const clientData = prepareClient((options.servicePrefix ?? '') + name, group, options); + const clientData = prepareClient(servicePrefix + name, group, options); const renderedFile = renderFile('client.ejs', { ...clientData, - servicePrefix: options.servicePrefix || '', + servicePrefix, }); result += renderedFile || ''; diff --git a/src/index.spec.ts b/src/index.spec.ts index 5741c3e..dede2d4 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -69,8 +69,8 @@ describe('runCodeGenerator', () => { out: './.tmp/test/', }; - const res = await runCodeGenerator(parameters); - expect(res).to.be.ok; + const conf = await runCodeGenerator(parameters); + expect(conf).to.be.ok; }); it('fails when wrong --config provided', async () => { @@ -116,8 +116,22 @@ describe('runCodeGenerator', () => { return expect(e).to.contain('Could not correctly load config file'); } }); +}); + +describe('applyConfigFile', () => { + it('should use default values', async () => { + const parameters = { src: './test/petstore-v3.yml', out: './.tmp/test/' }; + + const conf = await applyConfigFile(parameters); + + expect(conf).to.be.ok; + expect(conf.queryParamsSerialization).to.deep.equal({ + arrayFormat: 'repeat', + allowDots: true, + }); + }); - it('properly loads configuration from config file', async () => { + it('should load configuration from config file', async () => { const parameters = { config: './test/sample-config.json', }; @@ -129,13 +143,19 @@ describe('runCodeGenerator', () => { expect(conf.src).to.be.equal( 'https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json' ); + expect(conf.queryParamsSerialization).to.deep.equal({ + arrayFormat: 'repeat', + allowDots: true, + }); }); - it('makes inline parameters higher priority than from config file', async () => { + it('should treat inline parameters with a higher priority', async () => { const parameters = { config: './test/sample-config.json', baseUrl: 'https://wp.pl', src: './test/petstore-v3.yml', + arrayFormat: 'indices', + allowDots: false, }; const conf = await applyConfigFile(parameters); @@ -143,5 +163,9 @@ describe('runCodeGenerator', () => { expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://wp.pl'); expect(conf.src).to.be.equal('./test/petstore-v3.yml'); + expect(conf.queryParamsSerialization).to.deep.equal({ + arrayFormat: 'indices', + allowDots: false, + }); }); }); diff --git a/src/index.ts b/src/index.ts index 872fbf7..d8a8715 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import generateCode from './gen'; -import type { ClientOptions, FullAppOptions } from './types'; +import type { ArrayFormat, ClientOptions, FullAppOptions } from './types'; import { loadSpecDocument, verifyDocumentSpec, loadAllTemplateFiles } from './utils'; /** @@ -41,7 +41,7 @@ function gen(spec: OA3.Document, options: ClientOptions): Promise { export async function applyConfigFile(options: Partial): Promise { try { if (!options.config) { - return options as ClientOptions; + return prepareAppOptions(options as CliOptions); } const configUrl = options.config; @@ -52,7 +52,7 @@ export async function applyConfigFile(options: Partial): Promise `Could not correctly parse config file from "${configUrl}". Is it a valid JSON file?` ); } - return { ...parsedConfig, ...options }; + return prepareAppOptions({ ...parsedConfig, ...options }); } catch (e) { return Promise.reject( 'Could not correctly load config file. It does not exist or you cannot access it' @@ -67,3 +67,32 @@ function readFile(filePath: string): Promise { } export type CodeGenResult = [string, ClientOptions]; + +interface CliOptions extends FullAppOptions { + allowDots?: boolean; + arrayFormat?: ArrayFormat; +} + +const defaultQueryParamsConfig = { + allowDots: true, + arrayFormat: 'repeat' as const, +}; + +/** + * CLI options are flat, but within the app we use nested objects. + * This function converts flat options structure to the nested one and + * merges it with the default values. + * */ +function prepareAppOptions(cliOpts: CliOptions): FullAppOptions { + const { allowDots, arrayFormat, queryParamsSerialization = {}, ...rest } = cliOpts; + const mergedQueryParamsSerialization = { + ...defaultQueryParamsConfig, + ...Object.fromEntries( + Object.entries(queryParamsSerialization).filter(([_, v]) => v !== undefined) + ), + ...(allowDots !== undefined ? { allowDots } : {}), + ...(arrayFormat !== undefined ? { arrayFormat } : {}), + }; + + return { ...rest, queryParamsSerialization: mergedQueryParamsSerialization }; +} diff --git a/src/types.ts b/src/types.ts index dff8dd7..d814d8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,10 @@ import type { OpenAPIV3 as OA3 } from 'openapi-types'; +interface QueryParamsSerializationOptions { + allowDots?: boolean; + arrayFormat?: ArrayFormat; +} + export interface ClientOptions { /** * Path or URL to the Swagger specification file (JSON or YAML). @@ -14,17 +19,20 @@ export interface ClientOptions { preferAny?: boolean; servicePrefix?: string; /** How date should be handled. It does not do any special serialization */ - dateFormat?: DateSupport; // 'luxon', 'momentjs', etc + dateFormat?: DateSupport; + /** Options for query parameters serialization */ + queryParamsSerialization: QueryParamsSerializationOptions; } export interface FullAppOptions extends ClientOptions { - /** Path to the configuration file that contains actual config tp be used */ + /** Path to the configuration file that contains actual config to be used */ config?: string; } export type Template = 'axios' | 'fetch' | 'ng1' | 'ng2' | 'swr-axios' | 'xior'; export type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch'; export type DateSupport = 'string' | 'Date'; // 'luxon', 'momentjs', etc +export type ArrayFormat = 'indices' | 'repeat' | 'brackets'; /** * Local type that represent Operation as understood by Swaggie diff --git a/src/utils/test.utils.ts b/src/utils/test.utils.ts index 9526761..7450639 100644 --- a/src/utils/test.utils.ts +++ b/src/utils/test.utils.ts @@ -32,6 +32,10 @@ export function getClientOptions(opts: Partial = {}): ClientOptio src: 'http://example.com/swagger.json', out: 'output.ts', template: 'xior', + queryParamsSerialization: { + allowDots: true, + arrayFormat: 'repeat', + }, ...opts, }; } diff --git a/templates/axios/baseClient.ejs b/templates/axios/baseClient.ejs index 0c92c54..6516703 100644 --- a/templates/axios/baseClient.ejs +++ b/templates/axios/baseClient.ejs @@ -15,8 +15,8 @@ export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' %>', paramsSerializer: (params) => encodeParams(params, null, { - allowDots: true, - arrayFormat: 'repeat', + allowDots: <%= it.allowDots %>, + arrayFormat: '<%= it.arrayFormat %>', }), }); diff --git a/templates/fetch/baseClient.ejs b/templates/fetch/baseClient.ejs index 1e2bf88..7113673 100644 --- a/templates/fetch/baseClient.ejs +++ b/templates/fetch/baseClient.ejs @@ -13,8 +13,8 @@ export const defaults = { baseUrl: '<%= it.baseUrl || '' %>', paramsSerializer: (params) => encodeParams(params, null, { - allowDots: true, - arrayFormat: 'repeat', + allowDots: <%= it.allowDots %>, + arrayFormat: '<%= it.arrayFormat %>', }), }; diff --git a/templates/ng2/barrel.ejs b/templates/ng2/barrel.ejs index 5d28162..83f397e 100644 --- a/templates/ng2/barrel.ejs +++ b/templates/ng2/barrel.ejs @@ -56,10 +56,3 @@ function encodeParams( return encodedParams.join('&'); } -function paramsSerializer(params: any) { - return encodeParams(params, true, null, { - allowDots: true, - arrayFormat: 'repeat', - }); -} - diff --git a/templates/ng2/baseClient.ejs b/templates/ng2/baseClient.ejs index b4ee26a..7ed0fc4 100644 --- a/templates/ng2/baseClient.ejs +++ b/templates/ng2/baseClient.ejs @@ -64,3 +64,10 @@ abstract class BaseService { } } +function paramsSerializer(params: any) { + return encodeParams(params, null, { + allowDots: <%= it.allowDots %>, + arrayFormat: '<%= it.arrayFormat %>', + }); +} + diff --git a/templates/swr-axios/baseClient.ejs b/templates/swr-axios/baseClient.ejs index 542255e..da99eb0 100644 --- a/templates/swr-axios/baseClient.ejs +++ b/templates/swr-axios/baseClient.ejs @@ -16,8 +16,8 @@ export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' -%>', paramsSerializer: (params) => encodeParams(params, null, { - allowDots: true, - arrayFormat: 'repeat', + allowDots: <%= it.allowDots %>, + arrayFormat: '<%= it.arrayFormat %>', }), }); diff --git a/templates/xior/baseClient.ejs b/templates/xior/baseClient.ejs index 0185c44..e681176 100644 --- a/templates/xior/baseClient.ejs +++ b/templates/xior/baseClient.ejs @@ -15,8 +15,8 @@ export const http = xior.create({ baseURL: '<%= it.baseUrl || '' %>', paramsSerializer: (params) => encodeParams(params, true, null, { - allowDots: true, - arrayFormat: 'repeat', + allowDots: <%= it.allowDots %>, + arrayFormat: '<%= it.arrayFormat %>', }), }); diff --git a/test/ci-test.config.json b/test/ci-test.config.json index 7cde0d4..9ff5b49 100644 --- a/test/ci-test.config.json +++ b/test/ci-test.config.json @@ -4,5 +4,9 @@ "src": "./test/petstore-v3.json", "template": "axios", "preferAny": true, - "dateFormat": "string" + "dateFormat": "string", + "queryParamsSerialization": { + "arrayFormat": "repeat", + "allowDots": true + } } diff --git a/test/sample-config.json b/test/sample-config.json index 01521dc..b58d8ab 100644 --- a/test/sample-config.json +++ b/test/sample-config.json @@ -6,5 +6,9 @@ "baseUrl": "https://google.pl", "preferAny": true, "servicePrefix": "Test", - "dateFormat": "string" + "dateFormat": "string", + "queryParamsSerialization": { + "arrayFormat": "repeat", + "allowDots": true + } } diff --git a/test/snapshots.spec.ts b/test/snapshots.spec.ts index 0d71daa..10a24f9 100644 --- a/test/snapshots.spec.ts +++ b/test/snapshots.spec.ts @@ -14,6 +14,10 @@ describe('petstore snapshots', () => { src: './test/petstore-v3.yml', out: './.tmp/test/', template, + queryParamsSerialization: { + allowDots: true, + arrayFormat: 'repeat', + }, }; const [generatedCode] = await runCodeGenerator(parameters); diff --git a/test/snapshots/ng2.ts b/test/snapshots/ng2.ts index 2f32440..2feddf6 100644 --- a/test/snapshots/ng2.ts +++ b/test/snapshots/ng2.ts @@ -64,6 +64,13 @@ abstract class BaseService { } } +function paramsSerializer(params: any) { + return encodeParams(params, null, { + allowDots: true, + arrayFormat: 'repeat', + }); +} + @Injectable({ providedIn: 'root' }) @@ -490,13 +497,6 @@ function encodeParams( return encodedParams.join('&'); } -function paramsSerializer(params: any) { - return encodeParams(params, true, null, { - allowDots: true, - arrayFormat: 'repeat', - }); -} - export interface Order { id?: number; petId?: number; From 51ec643d27368409635e2f1f0f14bd6e1d3154d6 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 10 Jul 2024 12:48:26 +0200 Subject: [PATCH 23/27] fix: default value for template was too aggressive fix: small template fixes --- README.md | 2 +- package.json | 2 +- src/cli.ts | 6 +----- src/index.spec.ts | 7 ++++++- src/index.ts | 15 +++++++-------- src/types.ts | 5 +++++ templates/axios/baseClient.ejs | 2 +- templates/fetch/baseClient.ejs | 2 +- templates/swr-axios/baseClient.ejs | 2 +- test/sample-config.json | 2 +- test/snapshots/axios.ts | 2 +- test/snapshots/fetch.ts | 2 +- test/snapshots/swr-axios.ts | 2 +- 13 files changed, 28 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 730d524..0413bd1 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Options: -s, --src The url or path to the Open API spec file -o, --out The path to the file where the API would be generated. Use stdout if left empty -b, --baseUrl Base URL that will be used as a default value in the clients (default: "") - -t, --template Template used forgenerating API client. Default: "axios" (default: "axios") + -t, --template Template used forgenerating API client. Default: "axios" --preferAny Use "any" type instead of "unknown" (default: false) --servicePrefix Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions (default: "") --allowDots Determines if dots should be used for serialization object properties diff --git a/package.json b/package.json index d9aba7e..45cfef2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.2", + "version": "1.0.0-beta.3", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", diff --git a/src/cli.ts b/src/cli.ts index 2e10c81..06dbe46 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,11 +36,7 @@ program 'Base URL that will be used as a default value in the clients', '' ) - .option( - '-t, --template ', - 'Template used forgenerating API client. Default: "axios"', - 'axios' - ) + .option('-t, --template ', 'Template used forgenerating API client. Default: "axios"') .option('--preferAny', 'Use "any" type instead of "unknown"', false) .option( '--servicePrefix ', diff --git a/src/index.spec.ts b/src/index.spec.ts index dede2d4..2b725b7 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -3,6 +3,7 @@ import { MockAgent, setGlobalDispatcher } from 'undici'; import { runCodeGenerator, applyConfigFile } from './'; import { mockRequest } from './utils'; +import type { CliOptions } from './types'; describe('runCodeGenerator', () => { let mockAgent: MockAgent; @@ -129,6 +130,7 @@ describe('applyConfigFile', () => { arrayFormat: 'repeat', allowDots: true, }); + expect(conf.template).to.be.equal('axios'); }); it('should load configuration from config file', async () => { @@ -147,15 +149,17 @@ describe('applyConfigFile', () => { arrayFormat: 'repeat', allowDots: true, }); + expect(conf.template).to.be.equal('xior'); }); it('should treat inline parameters with a higher priority', async () => { - const parameters = { + const parameters: Partial = { config: './test/sample-config.json', baseUrl: 'https://wp.pl', src: './test/petstore-v3.yml', arrayFormat: 'indices', allowDots: false, + template: 'fetch', }; const conf = await applyConfigFile(parameters); @@ -163,6 +167,7 @@ describe('applyConfigFile', () => { expect(conf).to.be.ok; expect(conf.baseUrl).to.be.equal('https://wp.pl'); expect(conf.src).to.be.equal('./test/petstore-v3.yml'); + expect(conf.template).to.be.equal('fetch'); expect(conf.queryParamsSerialization).to.deep.equal({ arrayFormat: 'indices', allowDots: false, diff --git a/src/index.ts b/src/index.ts index d8a8715..3f4d1c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import type { OpenAPIV3 as OA3 } from 'openapi-types'; import generateCode from './gen'; -import type { ArrayFormat, ClientOptions, FullAppOptions } from './types'; +import type { ClientOptions, CliOptions, FullAppOptions } from './types'; import { loadSpecDocument, verifyDocumentSpec, loadAllTemplateFiles } from './utils'; /** @@ -68,11 +68,6 @@ function readFile(filePath: string): Promise { export type CodeGenResult = [string, ClientOptions]; -interface CliOptions extends FullAppOptions { - allowDots?: boolean; - arrayFormat?: ArrayFormat; -} - const defaultQueryParamsConfig = { allowDots: true, arrayFormat: 'repeat' as const, @@ -84,7 +79,7 @@ const defaultQueryParamsConfig = { * merges it with the default values. * */ function prepareAppOptions(cliOpts: CliOptions): FullAppOptions { - const { allowDots, arrayFormat, queryParamsSerialization = {}, ...rest } = cliOpts; + const { allowDots, arrayFormat, template, queryParamsSerialization = {}, ...rest } = cliOpts; const mergedQueryParamsSerialization = { ...defaultQueryParamsConfig, ...Object.fromEntries( @@ -94,5 +89,9 @@ function prepareAppOptions(cliOpts: CliOptions): FullAppOptions { ...(arrayFormat !== undefined ? { arrayFormat } : {}), }; - return { ...rest, queryParamsSerialization: mergedQueryParamsSerialization }; + return { + ...rest, + queryParamsSerialization: mergedQueryParamsSerialization, + template: template ?? 'axios', + }; } diff --git a/src/types.ts b/src/types.ts index d814d8c..a7ebcbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,6 +24,11 @@ export interface ClientOptions { queryParamsSerialization: QueryParamsSerializationOptions; } +export interface CliOptions extends FullAppOptions { + allowDots?: boolean; + arrayFormat?: ArrayFormat; +} + export interface FullAppOptions extends ClientOptions { /** Path to the configuration file that contains actual config to be used */ config?: string; diff --git a/templates/axios/baseClient.ejs b/templates/axios/baseClient.ejs index 6516703..0ad08b6 100644 --- a/templates/axios/baseClient.ejs +++ b/templates/axios/baseClient.ejs @@ -13,7 +13,7 @@ import Axios, { type AxiosPromise, type AxiosRequestConfig } from "axios"; export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' %>', - paramsSerializer: (params) => + paramsSerializer: (params: any) => encodeParams(params, null, { allowDots: <%= it.allowDots %>, arrayFormat: '<%= it.arrayFormat %>', diff --git a/templates/fetch/baseClient.ejs b/templates/fetch/baseClient.ejs index 7113673..643e93b 100644 --- a/templates/fetch/baseClient.ejs +++ b/templates/fetch/baseClient.ejs @@ -11,7 +11,7 @@ export const defaults = { baseUrl: '<%= it.baseUrl || '' %>', - paramsSerializer: (params) => + paramsSerializer: (params: any) => encodeParams(params, null, { allowDots: <%= it.allowDots %>, arrayFormat: '<%= it.arrayFormat %>', diff --git a/templates/swr-axios/baseClient.ejs b/templates/swr-axios/baseClient.ejs index da99eb0..c1cac6a 100644 --- a/templates/swr-axios/baseClient.ejs +++ b/templates/swr-axios/baseClient.ejs @@ -14,7 +14,7 @@ import useSWR, { type SWRConfiguration, type Key } from 'swr'; export const axios = Axios.create({ baseURL: '<%= it.baseUrl || '' -%>', - paramsSerializer: (params) => + paramsSerializer: (params: any) => encodeParams(params, null, { allowDots: <%= it.allowDots %>, arrayFormat: '<%= it.arrayFormat %>', diff --git a/test/sample-config.json b/test/sample-config.json index b58d8ab..68fae48 100644 --- a/test/sample-config.json +++ b/test/sample-config.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/yhnavein/swaggie/master/schema.json", "out": "./.tmp/test1", "src": "https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.json", - "template": "axios", + "template": "xior", "baseUrl": "https://google.pl", "preferAny": true, "servicePrefix": "Test", diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index d7228fa..2477d8f 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -13,7 +13,7 @@ import Axios, { type AxiosPromise, type AxiosRequestConfig } from "axios"; export const axios = Axios.create({ baseURL: '', - paramsSerializer: (params) => + paramsSerializer: (params: any) => encodeParams(params, null, { allowDots: true, arrayFormat: 'repeat', diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index 5ce0b50..da8f9b9 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -11,7 +11,7 @@ export const defaults = { baseUrl: '', - paramsSerializer: (params) => + paramsSerializer: (params: any) => encodeParams(params, null, { allowDots: true, arrayFormat: 'repeat', diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index 3c54c3a..b6c8442 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -14,7 +14,7 @@ import useSWR, { type SWRConfiguration, type Key } from 'swr'; export const axios = Axios.create({ baseURL: '', - paramsSerializer: (params) => + paramsSerializer: (params: any) => encodeParams(params, null, { allowDots: true, arrayFormat: 'repeat', From 33cf7404cbeb829315fa85acabd7d7041557414b Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 10 Jul 2024 16:04:43 +0200 Subject: [PATCH 24/27] fix: support correctly Swashbuckle's UseOneOfForPolymorphism impr: example on how Swashbuckle can generate more useful enums --- package.json | 2 +- .../Swaggie.Swashbuckle/EnumFilter.cs | 35 +++++++++++++++++++ .../Swaggie.Swashbuckle/QueryFilter.cs | 6 ++++ .../Swaggie.Swashbuckle/Startup.cs | 1 + src/gen/genTypes.spec.ts | 28 +++++++++++++++ src/gen/genTypes.ts | 9 ++++- 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/EnumFilter.cs diff --git a/package.json b/package.json index 45cfef2..807f4fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/EnumFilter.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/EnumFilter.cs new file mode 100644 index 0000000..0f355fe --- /dev/null +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/EnumFilter.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Swaggie.Swashbuckle; + +// ReSharper disable once ClassNeverInstantiated.Global +/// +/// Filter that fixes the way enums are generated into the OpenAPI schema +/// It will add an x-enumNames extension to the schema, which will contain the names of the enum values +/// +public class XEnumNamesSchemaFilter : ISchemaFilter +{ + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (context.Type.IsEnum) + { + schema.Enum.Clear(); + var enumValues = Enum.GetValues(context.Type); + var enumNames = Enum.GetNames(context.Type); + + var enumNamesArray = new OpenApiArray(); + enumNamesArray.AddRange(enumNames.Select(name => new OpenApiString(name))); + + schema.Extensions.Add("x-enumNames", enumNamesArray); + + foreach (var enumValue in enumValues) + { + schema.Enum.Add(new OpenApiInteger((int)enumValue)); + } + } + } +} diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs index 36570cf..42a55d8 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/QueryFilter.cs @@ -9,6 +9,12 @@ namespace Swaggie.Swashbuckle; // ReSharper disable once ClassNeverInstantiated.Global +/// +/// Filter that replaces original complex query parameters with the reference types instead +/// This will work only for OpenAPI 3.0 (as Swagger does not offer such functionality) +/// Without it, Swashbuckle will flatten objects into query parameters, which will effectively +/// break the contract with the API +/// public class FromQueryModelFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs index aaa8113..2caf636 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Startup.cs @@ -48,6 +48,7 @@ public void ConfigureServices(IServiceCollection services) { c.SchemaGeneratorOptions.UseOneOfForPolymorphism = true; c.OperationFilter(); + c.SchemaFilter(); c.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); c.SwaggerDoc("v1", new OpenApiInfo { Title = "Sample Api", Version = "v1" }); diff --git a/src/gen/genTypes.spec.ts b/src/gen/genTypes.spec.ts index aa08af1..92e0536 100644 --- a/src/gen/genTypes.spec.ts +++ b/src/gen/genTypes.spec.ts @@ -399,6 +399,34 @@ export interface AuthenticationData extends BasicAuth { export interface AuthenticationData extends LoginPart, PasswordPart { rememberMe: boolean; signForSpam?: boolean; +}`); + }); + + it('should handle allOf combined with object directly', () => { + const res = generateTypes( + prepareSchemas({ + AuthenticationData: { + required: ['rememberMe'], + type: 'object', + allOf: [{ $ref: '#/components/schemas/LoginPart' }], + + properties: { + rememberMe: { + type: 'boolean', + }, + signForSpam: { + type: 'boolean', + }, + }, + }, + }), + opts + ); + + expect(res).to.equalWI(` +export interface AuthenticationData extends LoginPart { + rememberMe: boolean; + signForSpam?: boolean; }`); }); }); diff --git a/src/gen/genTypes.ts b/src/gen/genTypes.ts index a681df9..cdcdc86 100644 --- a/src/gen/genTypes.ts +++ b/src/gen/genTypes.ts @@ -186,9 +186,16 @@ export function renderComment(comment: string | null) { } function getMergedCompositeObjects(schema: OA3.SchemaObject) { - const composite = schema.allOf || schema.oneOf || schema.anyOf || []; + const { allOf, oneOf, anyOf, ...safeSchema } = schema; + const composite = allOf || oneOf || anyOf || []; const subSchemas = composite.filter((v) => !('$ref' in v)); + // This is the case where schema itself is of type object, with properties + // and at the same time has sub-schemas like `allOf` or similar + if (safeSchema.type === 'object' && 'properties' in safeSchema) { + subSchemas.push(safeSchema); + } + return deepMerge({}, ...subSchemas); } From f04e071ace44999f9367b55dcc15cb3130eaa04f Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Wed, 10 Jul 2024 16:18:11 +0200 Subject: [PATCH 25/27] fix: last few issues with default CLI values --- package.json | 2 +- src/cli.ts | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 807f4fe..7f2e7f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.4", + "version": "1.0.0-beta.5", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", diff --git a/src/cli.ts b/src/cli.ts index 06dbe46..3ea5a38 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,17 +31,12 @@ program 'The path to the file where the API would be generated. Use stdout if left empty', process.env.OPEN_API_OUT ) - .option( - '-b, --baseUrl ', - 'Base URL that will be used as a default value in the clients', - '' - ) + .option('-b, --baseUrl ', 'Base URL that will be used as a default value in the clients') .option('-t, --template ', 'Template used forgenerating API client. Default: "axios"') - .option('--preferAny', 'Use "any" type instead of "unknown"', false) + .option('--preferAny', 'Use "any" type instead of "unknown"') .option( '--servicePrefix ', - 'Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions', - '' + 'Prefix for service names. Useful when you have multiple APIs and you want to avoid name collisions' ) .option( '--allowDots ', From fc2f8cebc842494fbc282c942d99299b4bec66c7 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Thu, 11 Jul 2024 01:19:35 +0200 Subject: [PATCH 26/27] fix: adjust Content Type header in urlencoded case as some of the clients are not able to guess content type correctly fix: some fixes for SWR template --- package.json | 2 +- .../Controllers/UserController.cs | 49 +++++++++++ src/gen/genOperations.ts | 20 +++-- templates/fetch/operation.ejs | 18 ++-- templates/swr-axios/operation.ejs | 14 ++-- templates/swr-axios/swrOperation.ejs | 17 ++-- test/snapshots/axios.ts | 3 + test/snapshots/fetch.ts | 5 +- test/snapshots/swr-axios.ts | 83 +++++++++---------- test/snapshots/xior.ts | 3 + 10 files changed, 141 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index 7f2e7f2..0a6a284 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski", diff --git a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs index 3eacc36..73acdff 100644 --- a/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs +++ b/samples/dotnetcore/swashbuckle/Swaggie.Swashbuckle/Controllers/UserController.cs @@ -58,6 +58,42 @@ public IActionResult CreateUser([FromBody] UserViewModel user) return Created("some-url", user); } + [HttpPost("avatar")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult UploadAvatar(IFormFile file) + { + Console.WriteLine("avatar" + file); + + // Here you would typically save the file to a storage service + // For demonstration, we'll just return the file size + return Ok($"File uploaded successfully. Size: {file?.Length ?? 0} bytes"); + } + + [HttpPut("properties")] + [Consumes("application/x-www-form-urlencoded")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult UpdateUserProperties([FromForm] UserUpdateModel userUpdate) + { + Console.WriteLine("userUpdate: " + JsonConvert.SerializeObject(userUpdate)); + + // Here you would typically update the user in your database + return Ok($"User updated: Name = {userUpdate.Name}, Email = {userUpdate.Email}"); + } + + [HttpPost("profile")] + [Consumes("multipart/form-data")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult UpdateUserProfile([FromForm] UserProfileUpdateModel profileUpdate) + { + Console.WriteLine("profileUpdate: " + JsonConvert.SerializeObject(profileUpdate)); + + // Here you would typically update the user's profile in your database + return Ok($"Profile updated: Name = {profileUpdate.Name}, Bio = {profileUpdate.Bio}"); + } + [HttpDelete("{id:long}")] [Produces(typeof(void))] public IActionResult DeleteUser([FromRoute] long id) @@ -66,6 +102,19 @@ public IActionResult DeleteUser([FromRoute] long id) } } +public class UserUpdateModel +{ + public string Name { get; set; } + public string Email { get; set; } +} + +public class UserProfileUpdateModel +{ + public string Name { get; set; } + public string Bio { get; set; } + public IFormFile Avatar { get; set; } +} + public class UserViewModel { public string Name { get; set; } diff --git a/src/gen/genOperations.ts b/src/gen/genOperations.ts index ef0c402..36d459c 100644 --- a/src/gen/genOperations.ts +++ b/src/gen/genOperations.ts @@ -87,6 +87,15 @@ export function prepareOperations( params.sort((a, b) => a.original['x-position'] - b.original['x-position']); } + const headers = getParams(op.parameters as OA3.ParameterObject[], options, ['header']); + // Some libraries need to know the content type of the request body in case of urlencoded body + if (body?.contentType === 'urlencoded') { + headers.push({ + originalName: 'Content-Type', + value: 'application/x-www-form-urlencoded', + }); + } + return { returnType, responseContentType, @@ -96,7 +105,7 @@ export function prepareOperations( parameters: params, query: queryParams, body, - headers: getParams(op.parameters as OA3.ParameterObject[], options, ['header']), + headers, }; }); } @@ -229,10 +238,11 @@ interface IOperation { interface IOperationParam { originalName: string; - name: string; - type: string; - optional: boolean; - original: OA3.ParameterObject | OA3.RequestBodyObject; + name?: string; + type?: string; + value?: string; + optional?: boolean; + original?: OA3.ParameterObject | OA3.RequestBodyObject; } interface IBodyParam extends IOperationParam { diff --git a/templates/fetch/operation.ejs b/templates/fetch/operation.ejs index eedb338..283225a 100644 --- a/templates/fetch/operation.ejs +++ b/templates/fetch/operation.ejs @@ -18,20 +18,24 @@ $config?: RequestInit return fetch(url, { method: '<%= it.method %>', <% if(it.body) { %> -<% if(it.body.contentType === 'binary') { %> - body: <%= it.body.name %>, +<% if(it.body.contentType === 'json') { %> + body: JSON.stringify(<%= it.body.name %>), <% } else if(it.body.contentType === 'urlencoded') { %> body: new URLSearchParams(<%= it.body.name %> as any), <% } else { %> - body: JSON.stringify(<%= it.body.name %>), + body: <%= it.body.name %>, <% } %> <% } %> <% if(it.headers && it.headers.length > 0) { %> headers: { - <% it.headers.forEach((parameter) => { %> - '<%= parameter.originalName %>': <%= parameter.name %> ?? '', - <% }); %> - }, + <% it.headers.forEach((parameter) => { %> + <% if (parameter.value) { %> + '<%= parameter.originalName %>': '<%= parameter.value %>', + <% } else { %> + '<%= parameter.originalName %>': <%= parameter.name %> ?? '', + <% } %> + <% }); %> +}, <% } %> ...$config, }) diff --git a/templates/swr-axios/operation.ejs b/templates/swr-axios/operation.ejs index fce67dd..00943c6 100644 --- a/templates/swr-axios/operation.ejs +++ b/templates/swr-axios/operation.ejs @@ -23,21 +23,21 @@ <% } %> <% if(it.query && it.query.length > 0) { %> params: { - <% it.query.forEach((parameter) => { %> + <% it.query.forEach((parameter) => { %> '<%= parameter.originalName %>': <%= parameter.name %>, <% }); %> - }, + }, <% } %> <% if(it.headers && it.headers.length > 0) { %> headers: { - <% it.headers.forEach((parameter) => { %> + <% it.headers.forEach((parameter) => { %> <% if (parameter.value) { %> - '<%= parameter.originalName %>': '<%= parameter.value %>', + '<%= parameter.originalName %>': '<%= parameter.value %>', <% } else { %> - '<%= parameter.originalName %>': <%= parameter.name %>, + '<%= parameter.originalName %>': <%= parameter.name %>, <% } %> - <% }); %> - }, + <% }); %> +}, <% } %> ...$config, }); diff --git a/templates/swr-axios/swrOperation.ejs b/templates/swr-axios/swrOperation.ejs index f805cd4..07f0e19 100644 --- a/templates/swr-axios/swrOperation.ejs +++ b/templates/swr-axios/swrOperation.ejs @@ -12,15 +12,12 @@ export function <%= it.swrOpName %>(<% it.parameters.forEach((parameter) => { %> const url = `<%= it.url %>`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; -<% if(it.query && it.query.length > 0) { %> - <% it.query.forEach((parameter) => { %> -if (!!<%= parameter.name %>) { - cacheUrl += `<%= parameter.originalName %>=${<%= parameter.name %>}&`; - } + const cacheUrl = `${url}?<% + if(it.query && it.query.length > 0) { %>${encodeParams({<% + it.query.forEach((parameter) => { %> +'<%= parameter.originalName %>': <%= parameter.name %>, + <% }); %>})}<% } %>`; - <% }); %> -<% } %> const { data, error, mutate } = useSWR<<%~ it.returnType %>>( key ?? cacheUrl, () => axios.request({ @@ -28,10 +25,10 @@ const { data, error, mutate } = useSWR<<%~ it.returnType %>>( method: '<%= it.method %>', <% if(it.query && it.query.length > 0) { %> params: { - <% it.query.forEach((parameter) => { %> + <% it.query.forEach((parameter) => { %> '<%= parameter.originalName %>': <%= parameter.name %>, <% }); %> - }, +}, <% } %> <% if(it.headers && it.headers.length > 0) { %> headers: { diff --git a/test/snapshots/axios.ts b/test/snapshots/axios.ts index 2477d8f..fc7eae2 100644 --- a/test/snapshots/axios.ts +++ b/test/snapshots/axios.ts @@ -120,6 +120,9 @@ export const petClient = { url: url, method: 'PUT', data: new URLSearchParams(body as any), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, ...$config, }); }, diff --git a/test/snapshots/fetch.ts b/test/snapshots/fetch.ts index da8f9b9..963cdf5 100644 --- a/test/snapshots/fetch.ts +++ b/test/snapshots/fetch.ts @@ -113,6 +113,9 @@ export const petClient = { return fetch(url, { method: 'PUT', body: new URLSearchParams(body as any), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, ...$config, }) .then((response) => response.json() as Promise); @@ -329,7 +332,7 @@ export const userClient = { return fetch(url, { method: 'PUT', - body: JSON.stringify(body), + body: body, ...$config, }) .then((response) => response.json() as Promise); diff --git a/test/snapshots/swr-axios.ts b/test/snapshots/swr-axios.ts index b6c8442..ac01c0b 100644 --- a/test/snapshots/swr-axios.ts +++ b/test/snapshots/swr-axios.ts @@ -59,8 +59,8 @@ export const petClient = { url: url, method: 'DELETE', headers: { - 'api_key': apiKey, - }, + 'api_key': apiKey, + }, ...$config, }); }, @@ -77,8 +77,8 @@ export const petClient = { url: url, method: 'GET', params: { - 'status': status, - }, + 'status': status, + }, ...$config, }); }, @@ -95,8 +95,8 @@ export const petClient = { url: url, method: 'GET', params: { - 'tags': tags, - }, + 'tags': tags, + }, ...$config, }); }, @@ -128,6 +128,9 @@ export const petClient = { url: url, method: 'PUT', data: new URLSearchParams(body as any), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, ...$config, }); }, @@ -148,9 +151,9 @@ export const petClient = { url: url, method: 'POST', params: { - 'name': name, + 'name': name, 'status': status, - }, + }, ...$config, }); }, @@ -172,8 +175,8 @@ export const petClient = { method: 'POST', data: body, params: { - 'additionalMetadata': additionalMetadata, - }, + 'additionalMetadata': additionalMetadata, + }, ...$config, }); }, @@ -189,19 +192,17 @@ export function usepetfindPetsByStatus( status: ("available" | "pending" | "sol const url = `/pet/findByStatus`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; - if (!!status) { - cacheUrl += `status=${status}&`; - } + const cacheUrl = `${url}?${encodeParams({'status': status, + })}`; - const { data, error, mutate } = useSWR( +const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ url: url, method: 'GET', params: { - 'status': status, - }, + 'status': status, + }, ...$axiosConf}) .then((resp) => resp.data), config); @@ -223,19 +224,17 @@ export function usepetfindPetsByTags( tags: string[] | null | undefined, const url = `/pet/findByTags`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; - if (!!tags) { - cacheUrl += `tags=${tags}&`; - } + const cacheUrl = `${url}?${encodeParams({'tags': tags, + })}`; - const { data, error, mutate } = useSWR( +const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ url: url, method: 'GET', params: { - 'tags': tags, - }, + 'tags': tags, + }, ...$axiosConf}) .then((resp) => resp.data), config); @@ -257,7 +256,8 @@ export function usepetPetById( petId: number , const url = `/pet/${encodeURIComponent(`${petId}`)}`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; + const cacheUrl = `${url}?`; + const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ @@ -344,7 +344,8 @@ export function usestoreInventory( $config?: SwrConfig const url = `/store/inventory`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; + const cacheUrl = `${url}?`; + const { data, error, mutate } = useSWR<{ [key: string]: number }>( key ?? cacheUrl, () => axios.request({ @@ -371,7 +372,8 @@ export function usestoreOrderById( orderId: number , const url = `/store/order/${encodeURIComponent(`${orderId}`)}`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; + const cacheUrl = `${url}?`; + const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ @@ -466,9 +468,9 @@ const { data, error, mutate } = useSWR( url: url, method: 'GET', params: { - 'username': username, + 'username': username, 'password': password, - }, + }, ...$config, }); }, @@ -515,7 +517,8 @@ export function useuserUserByName( username: string , const url = `/user/${encodeURIComponent(`${username}`)}`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; + const cacheUrl = `${url}?`; + const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ @@ -544,24 +547,19 @@ export function useuserloginUser( username: string | null | undefined, const url = `/user/login`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; - if (!!username) { - cacheUrl += `username=${username}&`; - } - - if (!!password) { - cacheUrl += `password=${password}&`; - } + const cacheUrl = `${url}?${encodeParams({'username': username, + 'password': password, + })}`; - const { data, error, mutate } = useSWR( +const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ url: url, method: 'GET', params: { - 'username': username, + 'username': username, 'password': password, - }, + }, ...$axiosConf}) .then((resp) => resp.data), config); @@ -581,7 +579,8 @@ export function useuserlogoutUser( $config?: SwrConfig const url = `/user/logout`; const { axios: $axiosConf, key, ...config } = $config || {}; - let cacheUrl = url + '?'; + const cacheUrl = `${url}?`; + const { data, error, mutate } = useSWR( key ?? cacheUrl, () => axios.request({ diff --git a/test/snapshots/xior.ts b/test/snapshots/xior.ts index cd08c9c..64dbec4 100644 --- a/test/snapshots/xior.ts +++ b/test/snapshots/xior.ts @@ -120,6 +120,9 @@ export const petClient = { url: url, method: 'PUT', data: new URLSearchParams(body as any), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, ...$config, }); }, From e0c83af9b45fdde5d7d04ce4ba80ded1cdda0c46 Mon Sep 17 00:00:00 2001 From: Piotr Dabrowski Date: Thu, 11 Jul 2024 23:13:18 +0200 Subject: [PATCH 27/27] chore: release 1.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0a6a284..e050a29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "swaggie", - "version": "1.0.0-beta.6", + "version": "1.0.0", "description": "Generate TypeScript REST client code from an OpenAPI spec", "author": { "name": "Piotr Dabrowski",