diff --git a/README.md b/README.md index 4574b138c..7fb5c598a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [Quick Start](#quick-start) - [Installation](#installation) - [Configuration](#configuration) + - [Clients](#clients) - [Formatting](#formatting) - [Linting](#linting) - [Enums](#enums) @@ -96,6 +97,18 @@ export default { Alternatively, you can use `openapi-ts.config.js` and configure the export statement depending on your project setup. +### Clients + +We provide a variety of possible clients to use when generating your `openapi-ts` client. The following are available: + +- `angular`: An [Angular](https://angular.io/) client using [RxJS](https://rxjs.dev/). +- `axios`: An [Axios](https://axios-http.com/docs/intro) client. +- `fetch`: A [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) client. +- `node`: A [Node.js](https://nodejs.org/) client using [node-fetch](https://www.npmjs.com/package/node-fetch) client. +- `node-experimental`: A [Node.js](https://nodejs.org/) client using [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). + > NOTE: [Experimental until Node.js v21](https://nodejs.org/docs/latest-v21.x/api/globals.html#fetch) +- `xhr`: A [XMLHttpRequest](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) client. + ### Formatting By default, `openapi-ts` will automatically format your client according to your project configuration. To disable automatic formatting, set `format` to false diff --git a/src/index.ts b/src/index.ts index 5ac8ff610..7d92186cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,8 @@ const logClientMessage = (client: Config['client']) => { return console.log('✨ Creating Fetch client'); case 'node': return console.log('✨ Creating Node.js client'); + case 'node-experimental': + return console.log('✨ Creating Node.js (experimental) client'); case 'xhr': return console.log('✨ Creating XHR client'); } diff --git a/src/templates/core/request.hbs b/src/templates/core/request.hbs index 593798075..a009a0d49 100644 --- a/src/templates/core/request.hbs +++ b/src/templates/core/request.hbs @@ -2,4 +2,5 @@ {{~#equals @root.$config.client 'axios'}}{{>axios/request}}{{/equals~}} {{~#equals @root.$config.client 'fetch'}}{{>fetch/request}}{{/equals~}} {{~#equals @root.$config.client 'node'}}{{>node/request}}{{/equals~}} +{{~#equals @root.$config.client 'node-experimental'}}{{>fetch/request}}{{/equals~}} {{~#equals @root.$config.client 'xhr'}}{{>xhr/request}}{{/equals~}} diff --git a/src/templates/partials/base.hbs b/src/templates/partials/base.hbs index 5afa5801c..d4ab1c207 100644 --- a/src/templates/partials/base.hbs +++ b/src/templates/partials/base.hbs @@ -4,6 +4,7 @@ {{~#equals @root.$config.client 'axios'}}Blob{{/equals~}} {{~#equals @root.$config.client 'angular'}}Blob{{/equals~}} {{~#equals @root.$config.client 'node'}}Blob{{/equals~}} +{{~#equals @root.$config.client 'node-experimental'}}Blob{{/equals~}} {{~else~}} {{~#useDateType @root.$config format~}} Date diff --git a/src/types/config.ts b/src/types/config.ts index 0c8611af9..2a7fa6ba1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -7,7 +7,7 @@ export interface UserConfig { * The selected HTTP client (fetch, xhr, node or axios) * @default 'fetch' */ - client?: 'angular' | 'axios' | 'fetch' | 'node' | 'xhr'; + client?: 'angular' | 'axios' | 'fetch' | 'node' | 'node-experimental' | 'xhr'; /** * Generate JavaScript objects from enum definitions? * @default false diff --git a/src/utils/getHttpRequestName.ts b/src/utils/getHttpRequestName.ts index ee0290081..1a809269b 100644 --- a/src/utils/getHttpRequestName.ts +++ b/src/utils/getHttpRequestName.ts @@ -13,6 +13,7 @@ export const getHttpRequestName = (client: Config['client']): string => { case 'fetch': return 'FetchHttpRequest'; case 'node': + case 'node-experimental': return 'NodeHttpRequest'; case 'xhr': return 'XHRHttpRequest'; diff --git a/test/bin.spec.ts b/test/bin.spec.ts index 2ad4d3872..678a672e6 100755 --- a/test/bin.spec.ts +++ b/test/bin.spec.ts @@ -60,6 +60,7 @@ describe('bin', () => { expect(result.stdout.toString()).toContain(''); expect(result.stderr.toString()).toBe(''); }); + it('generates node client', async () => { const result = sync('node', [ './bin/index.js', @@ -75,6 +76,21 @@ describe('bin', () => { expect(result.stderr.toString()).toBe(''); }); + it('generates node experimental client', async () => { + const result = sync('node', [ + './bin/index.js', + '--input', + './test/spec/v3.json', + '--output', + './test/generated/bin', + '--client', + 'node-experimental', + '--no-write', + ]); + expect(result.stdout.toString()).toContain(''); + expect(result.stderr.toString()).toBe(''); + }); + it('generates xhr client', async () => { const result = sync('node', [ './bin/index.js', diff --git a/test/e2e/client.node-experimental.spec.ts b/test/e2e/client.node-experimental.spec.ts new file mode 100644 index 000000000..0c9f74442 --- /dev/null +++ b/test/e2e/client.node-experimental.spec.ts @@ -0,0 +1,155 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { cleanup } from './scripts/cleanup'; +import { compileWithTypescript } from './scripts/compileWithTypescript'; +import { generateClient } from './scripts/generateClient'; +import server from './scripts/server'; + +describe('client.node-experimental', () => { + beforeAll(async () => { + cleanup('client/node-experimental'); + await generateClient('client/node-experimental', 'v3', 'node-experimental', false, 'ApiClient'); + compileWithTypescript('client/node-experimental'); + await server.start('client/node-experimental'); + }, 30000); + + afterAll(async () => { + await server.stop(); + }); + + it('requests token', async () => { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const tokenRequest = vi.fn().mockResolvedValue('MY_TOKEN'); + const client = new ApiClient({ + TOKEN: tokenRequest, + USERNAME: undefined, + PASSWORD: undefined, + }); + const result = await client.simple.getCallWithoutParametersAndResponse(); + expect(tokenRequest.mock.calls.length).toBe(1); + // @ts-ignore + expect(result.headers.authorization).toBe('Bearer MY_TOKEN'); + }); + + it('uses credentials', async () => { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const client = new ApiClient({ + TOKEN: undefined, + USERNAME: 'username', + PASSWORD: 'password', + }); + const result = await client.simple.getCallWithoutParametersAndResponse(); + // @ts-ignore + expect(result.headers.authorization).toBe('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); + }); + + it('supports complex params', async () => { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const client = new ApiClient(); + // @ts-ignore + const result = await client.complex.complexTypes({ + first: { + second: { + third: 'Hello World!', + }, + }, + }); + expect(result).toBeDefined(); + }); + + it('support form data', async () => { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const client = new ApiClient(); + // @ts-ignore + const result = await client.parameters.callWithParameters( + 'valueHeader', + 'valueQuery', + 'valueForm', + 'valueCookie', + 'valuePath', + { + prop: 'valueBody', + } + ); + expect(result).toBeDefined(); + }); + + it('can abort the request', async () => { + let error; + try { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const client = new ApiClient(); + const promise = client.simple.getCallWithoutParametersAndResponse(); + setTimeout(() => { + promise.cancel(); + }, 10); + await promise; + } catch (e) { + error = (e as Error).message; + } + expect(error).toContain('Request aborted'); + }); + + it('should throw known error (500)', async () => { + let error; + try { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const client = new ApiClient(); + await client.error.testErrorCode(500); + } catch (err) { + error = JSON.stringify({ + name: err.name, + message: err.message, + url: err.url, + status: err.status, + statusText: err.statusText, + body: err.body, + }); + } + expect(error).toBe( + JSON.stringify({ + name: 'ApiError', + message: 'Custom message: Internal Server Error', + url: 'http://localhost:3000/base/api/v1.0/error?status=500', + status: 500, + statusText: 'Internal Server Error', + body: { + status: 500, + message: 'hello world', + }, + }) + ); + }); + + it('should throw unknown error (409)', async () => { + let error; + try { + const { ApiClient } = await import('./generated/client/node-experimental/index.js'); + const client = new ApiClient(); + await client.error.testErrorCode(409); + } catch (err) { + error = JSON.stringify({ + name: err.name, + message: err.message, + url: err.url, + status: err.status, + statusText: err.statusText, + body: err.body, + }); + } + expect(error).toBe( + JSON.stringify({ + name: 'ApiError', + message: + 'Generic Error: status: 409; status text: Conflict; body: {\n "status": 409,\n "message": "hello world"\n}', + url: 'http://localhost:3000/base/api/v1.0/error?status=409', + status: 409, + statusText: 'Conflict', + body: { + status: 409, + message: 'hello world', + }, + }) + ); + }); +}); diff --git a/test/e2e/scripts/generateClient.ts b/test/e2e/scripts/generateClient.ts index e3fd08644..c52e5eb5f 100644 --- a/test/e2e/scripts/generateClient.ts +++ b/test/e2e/scripts/generateClient.ts @@ -1,9 +1,10 @@ import { createClient } from '../../../'; +import type { Config } from '../../../src/types/config'; export const generateClient = async ( dir: string, version: string, - client: 'fetch' | 'xhr' | 'node' | 'axios' | 'angular', + client: Config['client'], useOptions: boolean = false, name?: string ) => { diff --git a/test/e2e/v2.node-experimental.spec.ts b/test/e2e/v2.node-experimental.spec.ts new file mode 100644 index 000000000..e30394e2e --- /dev/null +++ b/test/e2e/v2.node-experimental.spec.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { cleanup } from './scripts/cleanup'; +import { compileWithTypescript } from './scripts/compileWithTypescript'; +import { generateClient } from './scripts/generateClient'; +import server from './scripts/server'; + +describe('v2.node-experimental', () => { + beforeAll(async () => { + cleanup('v2/node-experimental'); + await generateClient('v2/node-experimental', 'v2', 'node-experimental'); + compileWithTypescript('v2/node-experimental'); + await server.start('v2/node-experimental'); + }, 30000); + + afterAll(async () => { + await server.stop(); + }); + + it('requests token', async () => { + const { OpenAPI, SimpleService } = await import('./generated/v2/node-experimental/index.js'); + const tokenRequest = vi.fn().mockResolvedValue('MY_TOKEN'); + OpenAPI.TOKEN = tokenRequest; + const result = await SimpleService.getCallWithoutParametersAndResponse(); + expect(tokenRequest.mock.calls.length).toBe(1); + // @ts-ignore + expect(result.headers.authorization).toBe('Bearer MY_TOKEN'); + }); + + it('supports complex params', async () => { + const { ComplexService } = await import('./generated/v2/node-experimental/index.js'); + const result = await ComplexService.complexTypes({ + // @ts-ignore + first: { + second: { + third: 'Hello World!', + }, + }, + }); + expect(result).toBeDefined(); + }); + + it('can abort the request', async () => { + let error; + try { + const { SimpleService } = await import('./generated/v2/node-experimental/index.js'); + const promise = SimpleService.getCallWithoutParametersAndResponse(); + setTimeout(() => { + promise.cancel(); + }, 10); + await promise; + } catch (e) { + error = (e as Error).message; + } + expect(error).toContain('Request aborted'); + }); +}); + +describe('v2.node-experimental useOptions', () => { + beforeAll(async () => { + cleanup('v2/node-experimental'); + await generateClient('v2/node-experimental', 'v2', 'node-experimental', true); + compileWithTypescript('v2/node-experimental'); + await server.start('v2/node-experimental'); + }, 30000); + + afterAll(async () => { + await server.stop(); + }); + + it('returns result body by default', async () => { + const { SimpleService } = await import('./generated/v2/node-experimental/index.js'); + const result = await SimpleService.getCallWithoutParametersAndResponse(); + // @ts-ignore + expect(result.body).toBeUndefined(); + }); + + it('returns result body', async () => { + const { SimpleService } = await import('./generated/v2/node-experimental/index.js'); + // @ts-ignore + const result = await SimpleService.getCallWithoutParametersAndResponse({ + _result: 'body', + }); + // @ts-ignore + expect(result.body).toBeUndefined(); + }); + + it('returns raw result', async ({ skip }) => { + skip(); + const { SimpleService } = await import('./generated/v2/node-experimental/index.js'); + // @ts-ignore + const result = await SimpleService.getCallWithoutParametersAndResponse({ + _result: 'raw', + }); + // @ts-ignore + expect(result.body).toBeDefined(); + }); +}); diff --git a/test/e2e/v3.node-experimental.spec.ts b/test/e2e/v3.node-experimental.spec.ts new file mode 100644 index 000000000..f332c56de --- /dev/null +++ b/test/e2e/v3.node-experimental.spec.ts @@ -0,0 +1,208 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { cleanup } from './scripts/cleanup'; +import { compileWithTypescript } from './scripts/compileWithTypescript'; +import { generateClient } from './scripts/generateClient'; +import server from './scripts/server'; + +describe('v3.node-experimental', () => { + beforeAll(async () => { + cleanup('v3/node-experimental'); + await generateClient('v3/node-experimental', 'v3', 'node-experimental'); + compileWithTypescript('v3/node-experimental'); + await server.start('v3/node-experimental'); + }, 30000); + + afterAll(async () => { + await server.stop(); + }); + + it('requests token', async () => { + const { OpenAPI, SimpleService } = await import('./generated/v3/node-experimental/index.js'); + const tokenRequest = vi.fn().mockResolvedValue('MY_TOKEN'); + OpenAPI.TOKEN = tokenRequest; + OpenAPI.USERNAME = undefined; + OpenAPI.PASSWORD = undefined; + const result = await SimpleService.getCallWithoutParametersAndResponse(); + expect(tokenRequest.mock.calls.length).toBe(1); + // @ts-ignore + expect(result.headers.authorization).toBe('Bearer MY_TOKEN'); + }); + + it('uses credentials', async () => { + const { OpenAPI, SimpleService } = await import('./generated/v3/node-experimental/index.js'); + OpenAPI.TOKEN = undefined; + OpenAPI.USERNAME = 'username'; + OpenAPI.PASSWORD = 'password'; + const result = await SimpleService.getCallWithoutParametersAndResponse(); + // @ts-ignore + expect(result.headers.authorization).toBe('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); + }); + + it('supports complex params', async () => { + const { ComplexService } = await import('./generated/v3/node-experimental/index.js'); + const result = await ComplexService.complexTypes({ + // @ts-ignore + first: { + second: { + third: 'Hello World!', + }, + }, + }); + expect(result).toBeDefined(); + }); + + it('support form data', async () => { + const { ParametersService } = await import('./generated/v3/node-experimental/index.js'); + const result = await ParametersService.callWithParameters( + 'valueHeader', + // @ts-ignore + 'valueQuery', + 'valueForm', + 'valueCookie', + 'valuePath', + { + prop: 'valueBody', + } + ); + expect(result).toBeDefined(); + }); + + it('support blob response data', async () => { + const { FileResponseService } = await import('./generated/v3/node-experimental/index.js'); + // @ts-ignore + const result = await FileResponseService.fileResponse('test'); + expect(result).toBeDefined(); + }); + + it('can abort the request', async () => { + let error; + try { + const { SimpleService } = await import('./generated/v3/node-experimental/index.js'); + const promise = SimpleService.getCallWithoutParametersAndResponse(); + setTimeout(() => { + promise.cancel(); + }, 10); + await promise; + } catch (e) { + error = (e as Error).message; + } + expect(error).toContain('Request aborted'); + }); + + it('should throw known error (500)', async () => { + let error; + try { + const { ErrorService } = await import('./generated/v3/node-experimental/index.js'); + // @ts-ignore + await ErrorService.testErrorCode(500); + } catch (err) { + error = JSON.stringify({ + name: err.name, + message: err.message, + url: err.url, + status: err.status, + statusText: err.statusText, + body: err.body, + }); + } + expect(error).toBe( + JSON.stringify({ + name: 'ApiError', + message: 'Custom message: Internal Server Error', + url: 'http://localhost:3000/base/api/v1.0/error?status=500', + status: 500, + statusText: 'Internal Server Error', + body: { + status: 500, + message: 'hello world', + }, + }) + ); + }); + + it('should throw unknown error (409)', async () => { + let error; + try { + const { ErrorService } = await import('./generated/v3/node-experimental/index.js'); + // @ts-ignore + await ErrorService.testErrorCode(409); + } catch (err) { + error = JSON.stringify({ + name: err.name, + message: err.message, + url: err.url, + status: err.status, + statusText: err.statusText, + body: err.body, + }); + } + expect(error).toBe( + JSON.stringify({ + name: 'ApiError', + message: + 'Generic Error: status: 409; status text: Conflict; body: {\n "status": 409,\n "message": "hello world"\n}', + url: 'http://localhost:3000/base/api/v1.0/error?status=409', + status: 409, + statusText: 'Conflict', + body: { + status: 409, + message: 'hello world', + }, + }) + ); + }); + + it('it should parse query params', async () => { + const { ParametersService } = await import('./generated/v3/node-experimental/index.js'); + const result = await ParametersService.postCallWithOptionalParam({ + // @ts-ignore + page: 0, + size: 1, + sort: ['location'], + }); + // @ts-ignore + expect(result.query).toStrictEqual({ parameter: { page: '0', size: '1', sort: 'location' } }); + }); +}); + +describe('v3.node useOptions', () => { + beforeAll(async () => { + cleanup('v3/node-experimental'); + await generateClient('v3/node-experimental', 'v3', 'node-experimental', true); + compileWithTypescript('v3/node-experimental'); + await server.start('v3/node-experimental'); + }, 30000); + + afterAll(async () => { + await server.stop(); + }); + + it('returns result body by default', async () => { + const { SimpleService } = await import('./generated/v3/node-experimental/index.js'); + const result = await SimpleService.getCallWithoutParametersAndResponse(); + // @ts-ignore + expect(result.body).toBeUndefined(); + }); + + it('returns result body', async () => { + const { SimpleService } = await import('./generated/v3/node-experimental/index.js'); + // @ts-ignore + const result = await SimpleService.getCallWithoutParametersAndResponse({ + _result: 'body', + }); + // @ts-ignore + expect(result.body).toBeUndefined(); + }); + + it('returns raw result', async ({ skip }) => { + skip(); + const { SimpleService } = await import('./generated/v3/node-experimental/index.js'); + // @ts-ignore + const result = await SimpleService.getCallWithoutParametersAndResponse({ + _result: 'raw', + }); + // @ts-ignore + expect(result.body).toBeDefined(); + }); +});