diff --git a/.changeset/strong-countries-fail.md b/.changeset/strong-countries-fail.md new file mode 100644 index 000000000..9ab128d0e --- /dev/null +++ b/.changeset/strong-countries-fail.md @@ -0,0 +1,7 @@ +--- +'@hey-api/client-axios': patch +'@hey-api/client-fetch': patch +'@hey-api/openapi-ts': patch +--- + +feat: support oauth2 and apiKey security schemes diff --git a/docs/openapi-ts/plugins.md b/docs/openapi-ts/plugins.md index 7494753aa..06bfae7b0 100644 --- a/docs/openapi-ts/plugins.md +++ b/docs/openapi-ts/plugins.md @@ -67,6 +67,7 @@ export interface Config { name: 'my-plugin'; /** * Name of the generated file. + * * @default 'my-plugin' */ output?: string; diff --git a/packages/client-axios/src/__tests__/utils.test.ts b/packages/client-axios/src/__tests__/utils.test.ts new file mode 100644 index 000000000..337dc7119 --- /dev/null +++ b/packages/client-axios/src/__tests__/utils.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getAuthToken, setAuthParams } from '../utils'; + +describe('getAuthToken', () => { + it('returns access token', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const token = await getAuthToken( + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + { + accessToken, + apiKey, + }, + ); + expect(accessToken).toHaveBeenCalled(); + expect(token).toBe('Bearer foo'); + }); + + it('returns nothing when accessToken function is undefined', async () => { + const apiKey = vi.fn().mockReturnValue('bar'); + const token = await getAuthToken( + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + { + apiKey, + }, + ); + expect(token).toBeUndefined(); + }); + + it('returns API key', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const token = await getAuthToken( + { + fn: 'apiKey', + in: 'header', + name: 'baz', + }, + { + accessToken, + apiKey, + }, + ); + expect(apiKey).toHaveBeenCalled(); + expect(token).toBe('bar'); + }); + + it('returns nothing when apiKey function is undefined', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const token = await getAuthToken( + { + fn: 'apiKey', + in: 'header', + name: 'baz', + }, + { + accessToken, + }, + ); + expect(token).toBeUndefined(); + }); +}); + +describe('setAuthParams', () => { + it('sets access token in headers', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const headers: Record = {}; + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(headers.baz).toBe('Bearer foo'); + expect(Object.keys(query).length).toBe(0); + }); + + it('sets access token in query', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const headers: Record = {}; + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'accessToken', + in: 'query', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(Object.keys(headers).length).toBe(0); + expect(query.baz).toBe('Bearer foo'); + }); + + it('sets first scheme only', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const headers: Record = {}; + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + { + fn: 'accessToken', + in: 'query', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(headers.baz).toBe('Bearer foo'); + expect(Object.keys(query).length).toBe(0); + }); + + it('sets first scheme with token', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue(undefined); + const headers: Record = {}; + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'apiKey', + in: 'header', + name: 'baz', + }, + { + fn: 'accessToken', + in: 'query', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(Object.keys(headers).length).toBe(0); + expect(query.baz).toBe('Bearer foo'); + }); +}); diff --git a/packages/client-axios/src/index.ts b/packages/client-axios/src/index.ts index cb721db62..ad10cf1b8 100644 --- a/packages/client-axios/src/index.ts +++ b/packages/client-axios/src/index.ts @@ -2,7 +2,13 @@ import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; import type { Client, Config } from './types'; -import { createConfig, getUrl, mergeConfigs, mergeHeaders } from './utils'; +import { + createConfig, + getUrl, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils'; export const createClient = (config: Config): Client => { let _config = mergeConfigs(createConfig(), config); @@ -27,11 +33,17 @@ export const createClient = (config: Config): Client => { const opts = { ..._config, ...options, - headers: mergeHeaders( - _config.headers, - options.headers, - ) as RawAxiosRequestHeaders, + axios: options.axios ?? _config.axios ?? instance, + headers: mergeHeaders(_config.headers, options.headers), }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } @@ -41,12 +53,11 @@ export const createClient = (config: Config): Client => { url: opts.url, }); - const _axios = opts.axios || instance; - try { - const response = await _axios({ + const response = await opts.axios({ ...opts, data: opts.body, + headers: opts.headers as RawAxiosRequestHeaders, params: opts.query, url, }); diff --git a/packages/client-axios/src/types.ts b/packages/client-axios/src/types.ts index 707a074b3..7fff7be39 100644 --- a/packages/client-axios/src/types.ts +++ b/packages/client-axios/src/types.ts @@ -12,9 +12,20 @@ type OmitKeys = Pick>; export interface Config extends Omit { + /** + * Access token or a function returning access token. The resolved token will + * be added to request payload as required. + */ + accessToken?: (() => Promise) | string | undefined; + /** + * API key or a function returning API key. The resolved key will be added + * to the request payload as required. + */ + apiKey?: (() => Promise) | string | undefined; /** * Axios implementation. You can use this option to provide a custom * Axios instance. + * * @default axios */ axios?: AxiosStatic; @@ -64,6 +75,7 @@ export interface Config responseTransformer?: (data: unknown) => Promise; /** * Throw an error instead of returning it in the response? + * * @default false */ throwOnError?: ThrowOnError; @@ -87,6 +99,10 @@ export interface RequestOptions< client?: Client; path?: Record; query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; url: Url; } @@ -101,6 +117,12 @@ export type RequestResult< | (AxiosError & { data: undefined; error: TError }) >; +export interface Security { + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; +} + type MethodFn = < Data = unknown, TError = unknown, diff --git a/packages/client-axios/src/utils.ts b/packages/client-axios/src/utils.ts index 2d3e8a8ee..f171ade00 100644 --- a/packages/client-axios/src/utils.ts +++ b/packages/client-axios/src/utils.ts @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -250,6 +250,53 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { return url; }; +export const getAuthToken = async ( + security: Security, + options: Pick, +): Promise => { + if (security.fn === 'accessToken') { + const token = + typeof options.accessToken === 'function' + ? await options.accessToken() + : options.accessToken; + return token ? `Bearer ${token}` : undefined; + } + + if (security.fn === 'apiKey') { + return typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey; + } +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Record; + }) => { + for (const scheme of security) { + const token = await getAuthToken(scheme, options); + + if (!token) { + continue; + } + + if (scheme.in === 'header') { + options.headers[scheme.name] = token; + } else if (scheme.in === 'query') { + if (!options.query) { + options.query = {}; + } + + options.query[scheme.name] = token; + } + + return; + } +}; + export const getUrl = ({ path, url, @@ -278,8 +325,8 @@ export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeHeaders = ( ...headers: Array['headers'] | undefined> -): Required['headers'] => { - const mergedHeaders: Required['headers'] = {}; +): Record => { + const mergedHeaders: Record = {}; for (const header of headers) { if (!header || typeof header !== 'object') { continue; @@ -289,7 +336,6 @@ export const mergeHeaders = ( for (const [key, value] of iterator) { if (value === null) { - // @ts-expect-error delete mergedHeaders[key]; } else if (Array.isArray(value)) { for (const v of value) { @@ -299,7 +345,6 @@ export const mergeHeaders = ( } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - // @ts-expect-error mergedHeaders[key] = typeof value === 'object' ? JSON.stringify(value) : (value as string); } diff --git a/packages/client-axios/test/index.test.ts b/packages/client-axios/test/index.test.ts deleted file mode 100644 index 9c9d1170f..000000000 --- a/packages/client-axios/test/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -describe('Axios client', () => { - it('works', () => { - expect(1).toBe(1); - }); -}); diff --git a/packages/client-fetch/test/index.test.ts b/packages/client-fetch/src/__tests__/index.test.ts similarity index 94% rename from packages/client-fetch/test/index.test.ts rename to packages/client-fetch/src/__tests__/index.test.ts index 588daf3d1..de88d72d3 100644 --- a/packages/client-fetch/test/index.test.ts +++ b/packages/client-fetch/src/__tests__/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createClient } from '../src/index'; +import { createClient } from '../index'; describe('buildUrl', () => { const client = createClient(); diff --git a/packages/client-fetch/src/__tests__/utils.test.ts b/packages/client-fetch/src/__tests__/utils.test.ts new file mode 100644 index 000000000..7189f016b --- /dev/null +++ b/packages/client-fetch/src/__tests__/utils.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getAuthToken, getParseAs, setAuthParams } from '../utils'; + +describe('getAuthToken', () => { + it('returns access token', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const token = await getAuthToken( + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + { + accessToken, + apiKey, + }, + ); + expect(accessToken).toHaveBeenCalled(); + expect(token).toBe('Bearer foo'); + }); + + it('returns nothing when accessToken function is undefined', async () => { + const apiKey = vi.fn().mockReturnValue('bar'); + const token = await getAuthToken( + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + { + apiKey, + }, + ); + expect(token).toBeUndefined(); + }); + + it('returns API key', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const token = await getAuthToken( + { + fn: 'apiKey', + in: 'header', + name: 'baz', + }, + { + accessToken, + apiKey, + }, + ); + expect(apiKey).toHaveBeenCalled(); + expect(token).toBe('bar'); + }); + + it('returns nothing when apiKey function is undefined', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const token = await getAuthToken( + { + fn: 'apiKey', + in: 'header', + name: 'baz', + }, + { + accessToken, + }, + ); + expect(token).toBeUndefined(); + }); +}); + +describe('getParseAs', () => { + const scenarios: Array<{ + content: Parameters[0]; + parseAs: ReturnType; + }> = [ + { + content: null, + parseAs: undefined, + }, + { + content: 'application/json', + parseAs: 'json', + }, + { + content: 'application/ld+json', + parseAs: 'json', + }, + { + content: 'application/ld+json;charset=utf-8', + parseAs: 'json', + }, + { + content: 'application/ld+json; charset=utf-8', + parseAs: 'json', + }, + { + content: 'multipart/form-data', + parseAs: 'formData', + }, + { + content: 'application/*', + parseAs: 'blob', + }, + { + content: 'audio/*', + parseAs: 'blob', + }, + { + content: 'image/*', + parseAs: 'blob', + }, + { + content: 'video/*', + parseAs: 'blob', + }, + { + content: 'text/*', + parseAs: 'text', + }, + { + content: 'unsupported', + parseAs: undefined, + }, + ]; + + it.each(scenarios)( + 'detects $content as $parseAs', + async ({ content, parseAs }) => { + expect(getParseAs(content)).toEqual(parseAs); + }, + ); +}); + +describe('setAuthParams', () => { + it('sets access token in headers', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const headers = new Headers(); + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(headers.get('baz')).toBe('Bearer foo'); + expect(Object.keys(query).length).toBe(0); + }); + + it('sets access token in query', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const headers = new Headers(); + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'accessToken', + in: 'query', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(headers.get('baz')).toBeNull(); + expect(query.baz).toBe('Bearer foo'); + }); + + it('sets first scheme only', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue('bar'); + const headers = new Headers(); + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'accessToken', + in: 'header', + name: 'baz', + }, + { + fn: 'accessToken', + in: 'query', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(headers.get('baz')).toBe('Bearer foo'); + expect(Object.keys(query).length).toBe(0); + }); + + it('sets first scheme with token', async () => { + const accessToken = vi.fn().mockReturnValue('foo'); + const apiKey = vi.fn().mockReturnValue(undefined); + const headers = new Headers(); + const query: Record = {}; + await setAuthParams({ + accessToken, + apiKey, + headers, + query, + security: [ + { + fn: 'apiKey', + in: 'header', + name: 'baz', + }, + { + fn: 'accessToken', + in: 'query', + name: 'baz', + }, + ], + }); + expect(accessToken).toHaveBeenCalled(); + expect(headers.get('baz')).toBeNull(); + expect(query.baz).toBe('Bearer foo'); + }); +}); diff --git a/packages/client-fetch/src/index.ts b/packages/client-fetch/src/index.ts index 356efd022..d96d8344c 100644 --- a/packages/client-fetch/src/index.ts +++ b/packages/client-fetch/src/index.ts @@ -1,12 +1,12 @@ import type { Client, Config, RequestOptions } from './types'; import { + buildUrl, createConfig, createInterceptors, - createQuerySerializer, getParseAs, - getUrl, mergeConfigs, mergeHeaders, + setAuthParams, } from './utils'; type ReqInit = Omit & { @@ -24,20 +24,6 @@ export const createClient = (config: Config = {}): Client => { return getConfig(); }; - const buildUrl: Client['buildUrl'] = (options) => { - const url = getUrl({ - baseUrl: options.baseUrl ?? '', - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - return url; - }; - const interceptors = createInterceptors< Request, Response, @@ -53,6 +39,14 @@ export const createClient = (config: Config = {}): Client => { fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } @@ -74,8 +68,7 @@ export const createClient = (config: Config = {}): Client => { request = await fn(request, opts); } - const _fetch = opts.fetch!; - let response = await _fetch(request); + let response = await opts.fetch(request); for (const fn of interceptors.response._fns) { response = await fn(response, request, opts); diff --git a/packages/client-fetch/src/types.ts b/packages/client-fetch/src/types.ts index 737ecf57b..0c914db6c 100644 --- a/packages/client-fetch/src/types.ts +++ b/packages/client-fetch/src/types.ts @@ -9,8 +9,19 @@ type OmitKeys = Pick>; export interface Config extends Omit { + /** + * Access token or a function returning access token. The resolved token + * will be added to request headers where it's required. + */ + accessToken?: (() => Promise) | string | undefined; + /** + * API key or a function returning API key. The resolved key will be added + * to the request payload as required. + */ + apiKey?: (() => Promise) | string | undefined; /** * Base URL for all requests made by this client. + * * @default '' */ baseUrl?: string; @@ -22,6 +33,7 @@ export interface Config /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. + * * @default globalThis.fetch */ fetch?: (request: Request) => ReturnType; @@ -63,6 +75,7 @@ export interface Config * will infer the appropriate method from the `Content-Type` response header. * You can override this behavior with any of the {@link Body} methods. * Select `stream` if you don't want to parse response data at all. + * * @default 'auto' */ parseAs?: Exclude | 'auto' | 'stream'; @@ -82,6 +95,7 @@ export interface Config responseTransformer?: (data: unknown) => Promise; /** * Throw an error instead of returning it in the response? + * * @default false */ throwOnError?: ThrowOnError; @@ -110,6 +124,10 @@ export interface RequestOptions< client?: Client; path?: Record; query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; url: Url; } @@ -133,6 +151,12 @@ export type RequestResult< } >; +export interface Security { + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; +} + type MethodFn = < Data = unknown, TError = unknown, diff --git a/packages/client-fetch/src/utils.ts b/packages/client-fetch/src/utils.ts index 19a2e4024..8b43a99de 100644 --- a/packages/client-fetch/src/utils.ts +++ b/packages/client-fetch/src/utils.ts @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Client, Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -358,6 +358,67 @@ export const getParseAs = ( } }; +export const getAuthToken = async ( + security: Security, + options: Pick, +): Promise => { + if (security.fn === 'accessToken') { + const token = + typeof options.accessToken === 'function' + ? await options.accessToken() + : options.accessToken; + return token ? `Bearer ${token}` : undefined; + } + + if (security.fn === 'apiKey') { + return typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey; + } +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const scheme of security) { + const token = await getAuthToken(scheme, options); + + if (!token) { + continue; + } + + if (scheme.in === 'header') { + options.headers.set(scheme.name, token); + } else if (scheme.in === 'query') { + if (!options.query) { + options.query = {}; + } + + options.query[scheme.name] = token; + } + + return; + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + baseUrl: options.baseUrl ?? '', + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + export const getUrl = ({ baseUrl, path, @@ -397,7 +458,7 @@ export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeHeaders = ( ...headers: Array['headers'] | undefined> -) => { +): Headers => { const mergedHeaders = new Headers(); for (const header of headers) { if (!header || typeof header !== 'object') { diff --git a/packages/openapi-ts/.gitignore b/packages/openapi-ts/.gitignore index 3d31689e4..99dc3c8df 100644 --- a/packages/openapi-ts/.gitignore +++ b/packages/openapi-ts/.gitignore @@ -3,6 +3,7 @@ .tsup .tmp junit.xml +logs node_modules npm-debug.log* temp diff --git a/packages/openapi-ts/src/ir/ir.d.ts b/packages/openapi-ts/src/ir/ir.d.ts index 1e3f8e2bd..1a7d5038a 100644 --- a/packages/openapi-ts/src/ir/ir.d.ts +++ b/packages/openapi-ts/src/ir/ir.d.ts @@ -1,4 +1,5 @@ import type { JsonSchemaDraft2020_12 } from '../openApi/3.1.x/types/json-schema-draft-2020-12'; +import type { SecuritySchemeObject } from '../openApi/3.1.x/types/spec'; import type { IRMediaType } from './mediaType'; export interface IR { @@ -36,8 +37,8 @@ export interface IROperationObject { parameters?: IRParametersObject; path: keyof IRPathsObject; responses?: IRResponsesObject; + security?: ReadonlyArray; // TODO: parser - add more properties - // security?: ReadonlyArray; // servers?: ReadonlyArray; summary?: string; tags?: ReadonlyArray; diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts index dea7a78d7..c6d05052a 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/index.ts @@ -6,6 +6,7 @@ import type { PathItemObject, PathsObject, RequestBodyObject, + SecuritySchemeObject, } from '../types/spec'; import { parseOperation } from './operation'; import { @@ -18,6 +19,7 @@ import { parseSchema } from './schema'; export const parseV3_0_X = (context: IRContext) => { const operationIds = new Map(); + const securitySchemesMap = new Map(); const excludeRegExp = context.config.input.exclude ? new RegExp(context.config.input.exclude) @@ -35,6 +37,15 @@ export const parseV3_0_X = (context: IRContext) => { // TODO: parser - handle more component types, old parser handles only parameters and schemas if (context.spec.components) { + for (const name in context.spec.components.securitySchemes) { + const securityOrReference = context.spec.components.securitySchemes[name]; + const securitySchemeObject = + '$ref' in securityOrReference + ? context.resolveRef(securityOrReference.$ref) + : securityOrReference; + securitySchemesMap.set(name, securitySchemeObject); + } + for (const name in context.spec.components.parameters) { const $ref = `#/components/parameters/${name}`; if (!shouldProcessRef($ref)) { @@ -117,11 +128,13 @@ export const parseV3_0_X = (context: IRContext) => { context, parameters: finalPathItem.parameters, }), + security: context.spec.security, servers: finalPathItem.servers, summary: finalPathItem.summary, }, operationIds, path: path as keyof PathsObject, + securitySchemesMap, }; const $refDelete = `#/paths${path}/delete`; diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts index ce8773d00..3850b701d 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts @@ -6,6 +6,7 @@ import type { PathItemObject, RequestBodyObject, ResponseObject, + SecuritySchemeObject, } from '../types/spec'; import { contentToSchema, mediaTypeObject } from './mediaType'; import { paginationField } from './pagination'; @@ -65,9 +66,11 @@ const operationToIrOperation = ({ method, operation, path, + securitySchemesMap, }: Pick & { context: IRContext; operation: Operation; + securitySchemesMap: Map; }): IROperationObject => { const irOperation = initIrOperation({ method, operation, path }); @@ -172,8 +175,22 @@ const operationToIrOperation = ({ } } - // TODO: parser - handle security - // baz: operation.security + if (operation.security) { + const securitySchemeObjects: Array = []; + + for (const securityRequirementObject of operation.security) { + for (const name in securityRequirementObject) { + const securitySchemeObject = securitySchemesMap.get(name); + if (securitySchemeObject) { + securitySchemeObjects.push(securitySchemeObject); + } + } + } + + if (securitySchemeObjects.length) { + irOperation.security = securitySchemeObjects; + } + } // TODO: parser - handle servers // qux: operation.servers @@ -187,6 +204,7 @@ export const parseOperation = ({ operation, operationIds, path, + securitySchemesMap, }: { context: IRContext; method: Extract< @@ -196,6 +214,7 @@ export const parseOperation = ({ operation: Operation; operationIds: Map; path: keyof IRPathsObject; + securitySchemesMap: Map; }) => { // TODO: parser - support throw on duplicate if (operation.operationId) { @@ -230,5 +249,6 @@ export const parseOperation = ({ method, operation, path, + securitySchemesMap, }); }; diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts index e0005a819..d3b6900ad 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/index.ts @@ -6,6 +6,7 @@ import type { PathItemObject, PathsObject, RequestBodyObject, + SecuritySchemeObject, } from '../types/spec'; import { parseOperation } from './operation'; import { @@ -18,6 +19,7 @@ import { parseSchema } from './schema'; export const parseV3_1_X = (context: IRContext) => { const operationIds = new Map(); + const securitySchemesMap = new Map(); const excludeRegExp = context.config.input.exclude ? new RegExp(context.config.input.exclude) @@ -35,6 +37,15 @@ export const parseV3_1_X = (context: IRContext) => { // TODO: parser - handle more component types, old parser handles only parameters and schemas if (context.spec.components) { + for (const name in context.spec.components.securitySchemes) { + const securityOrReference = context.spec.components.securitySchemes[name]; + const securitySchemeObject = + '$ref' in securityOrReference + ? context.resolveRef(securityOrReference.$ref) + : securityOrReference; + securitySchemesMap.set(name, securitySchemeObject); + } + for (const name in context.spec.components.parameters) { const $ref = `#/components/parameters/${name}`; if (!shouldProcessRef($ref)) { @@ -110,11 +121,13 @@ export const parseV3_1_X = (context: IRContext) => { context, parameters: finalPathItem.parameters, }), + security: context.spec.security, servers: finalPathItem.servers, summary: finalPathItem.summary, }, operationIds, path: path as keyof PathsObject, + securitySchemesMap, }; const $refDelete = `#/paths${path}/delete`; diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts index b9bb868db..ced1fc919 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts @@ -6,6 +6,7 @@ import type { PathItemObject, RequestBodyObject, ResponseObject, + SecuritySchemeObject, } from '../types/spec'; import { contentToSchema, mediaTypeObject } from './mediaType'; import { paginationField } from './pagination'; @@ -65,9 +66,11 @@ const operationToIrOperation = ({ method, operation, path, + securitySchemesMap, }: Pick & { context: IRContext; operation: Operation; + securitySchemesMap: Map; }): IROperationObject => { const irOperation = initIrOperation({ method, operation, path }); @@ -157,8 +160,22 @@ const operationToIrOperation = ({ } } - // TODO: parser - handle security - // baz: operation.security + if (operation.security) { + const securitySchemeObjects: Array = []; + + for (const securityRequirementObject of operation.security) { + for (const name in securityRequirementObject) { + const securitySchemeObject = securitySchemesMap.get(name); + if (securitySchemeObject) { + securitySchemeObjects.push(securitySchemeObject); + } + } + } + + if (securitySchemeObjects.length) { + irOperation.security = securitySchemeObjects; + } + } // TODO: parser - handle servers // qux: operation.servers @@ -172,6 +189,7 @@ export const parseOperation = ({ operation, operationIds, path, + securitySchemesMap, }: { context: IRContext; method: Extract< @@ -181,6 +199,7 @@ export const parseOperation = ({ operation: Operation; operationIds: Map; path: keyof IRPathsObject; + securitySchemesMap: Map; }) => { // TODO: parser - support throw on duplicate if (operation.operationId) { @@ -215,5 +234,6 @@ export const parseOperation = ({ method, operation, path, + securitySchemesMap, }); }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts index f5870535b..ca0f51c2a 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts @@ -9,6 +9,7 @@ export const defaultConfig: Plugin.Config = { _handlerLegacy: handlerLegacy, _optionalDependencies: ['@hey-api/transformers'], asClass: false, + auth: true, name: '@hey-api/sdk', operationId: true, output: 'sdk', diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts index 34666528a..a530a2ae3 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/plugin.ts @@ -77,9 +77,11 @@ const sdkId = 'sdk'; const operationStatements = ({ context, operation, + plugin, }: { context: IRContext; operation: IROperationObject; + plugin: Plugin.Instance; }): Array => { const file = context.file({ id: sdkId })!; const sdkOutput = file.nameWithoutExtension(); @@ -175,6 +177,48 @@ const operationStatements = ({ // content type. currently impossible because successes do not contain // header information + if (operation.security && plugin.auth) { + // TODO: parser - handle more security types + // type copied from client packages + const security: Array<{ + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; + }> = []; + + for (const securitySchemeObject of operation.security) { + if (securitySchemeObject.type === 'oauth2') { + if (securitySchemeObject.flows.password) { + security.push({ + fn: 'accessToken', + in: 'header', + name: 'Authorization', + }); + } + } else if (securitySchemeObject.type === 'apiKey') { + // TODO: parser - support cookies auth + if (securitySchemeObject.in !== 'cookie') { + security.push({ + fn: 'apiKey', + in: securitySchemeObject.in, + name: securitySchemeObject.name, + }); + } + } else { + console.warn( + `❗️ SDK warning: security scheme isn't currently supported. Please open an issue if you'd like it added https://github.com/hey-api/openapi-ts/issues\n${JSON.stringify(securitySchemeObject, null, 2)}`, + ); + } + } + + if (security.length) { + requestOptions.push({ + key: 'security', + value: compiler.arrayLiteralExpression({ elements: security }), + }); + } + } + requestOptions.push({ key: 'url', value: operation.path, @@ -248,7 +292,13 @@ const operationStatements = ({ ]; }; -const generateClassSdk = ({ context }: { context: IRContext }) => { +const generateClassSdk = ({ + context, + plugin, +}: { + context: IRContext; + plugin: Plugin.Instance; +}) => { const file = context.file({ id: sdkId })!; const typesModule = file.relativePathToFile({ context, id: 'types' }); @@ -292,7 +342,11 @@ const generateClassSdk = ({ context }: { context: IRContext }) => { }, ], returnType: undefined, - statements: operationStatements({ context, operation }), + statements: operationStatements({ + context, + operation, + plugin, + }), types: [ { default: false, @@ -330,7 +384,13 @@ const generateClassSdk = ({ context }: { context: IRContext }) => { }); }; -const generateFlatSdk = ({ context }: { context: IRContext }) => { +const generateFlatSdk = ({ + context, + plugin, +}: { + context: IRContext; + plugin: Plugin.Instance; +}) => { const file = context.file({ id: sdkId })!; const typesModule = file.relativePathToFile({ context, id: 'types' }); @@ -366,7 +426,11 @@ const generateFlatSdk = ({ context }: { context: IRContext }) => { }, ], returnType: undefined, - statements: operationStatements({ context, operation }), + statements: operationStatements({ + context, + operation, + plugin, + }), types: [ { default: false, @@ -438,8 +502,8 @@ export const handler: Plugin.Handler = ({ context, plugin }) => { file.add(statement); if (plugin.asClass) { - generateClassSdk({ context }); + generateClassSdk({ context, plugin }); } else { - generateFlatSdk({ context }); + generateFlatSdk({ context, plugin }); } }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts index 3579b2619..b2de7af43 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.d.ts @@ -11,18 +11,29 @@ export interface Config extends Plugin.Name<'@hey-api/sdk'> { * Note that by enabling this option, your SDKs will **NOT** * support {@link https://developer.mozilla.org/docs/Glossary/Tree_shaking tree-shaking}. * For this reason, it is disabled by default. + * * @default false */ asClass?: boolean; /** + * **This feature works only with the experimental parser** + * + * Should the generated functions contain auth mechanisms? You may want to + * disable this option if you're handling auth yourself or defining it + * globally on the client and want to reduce the size of generated code. + * + * @default true + */ + auth?: boolean; + /** + * **This feature works only with the legacy parser** + * * Filter endpoints to be included in the generated SDK. The provided * string should be a regular expression where matched results will be * included in the output. The input pattern this string will be tested * against is `{method} {path}`. For example, you can match * `POST /api/v1/foo` with `^POST /api/v1/foo$`. * - * This option does not work with the experimental parser. - * * @deprecated */ filter?: string; @@ -39,17 +50,21 @@ export interface Config extends Plugin.Name<'@hey-api/sdk'> { // TODO: parser - rename operationId option to something like inferId?: boolean /** * Use operation ID to generate operation names? + * * @default true */ operationId?: boolean; /** * Name of the generated file. + * * @default 'sdk' */ output?: string; /** * Define shape of returned value from service calls + * * @default 'body' + * * @deprecated */ response?: 'body' | 'response'; @@ -58,6 +73,7 @@ export interface Config extends Plugin.Name<'@hey-api/sdk'> { * obtained from your OpenAPI specification tags. * * This option has no effect if `sdk.asClass` is `false`. + * * @default '{{name}}Service' */ serviceNameBuilder?: string; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/types.d.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/types.d.ts index 51e84e882..df320201f 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/types.d.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/types.d.ts @@ -39,15 +39,16 @@ export interface Config extends Plugin.Name<'@hey-api/typescript'> { */ identifierCase?: Exclude; /** - * Include only types matching regular expression. + * **This feature works only with the legacy parser** * - * This option does not work with the experimental parser. + * Include only types matching regular expression. * * @deprecated */ include?: string; /** * Name of the generated file. + * * @default 'types' */ output?: string; diff --git a/packages/openapi-ts/test/3.0.x.test.ts b/packages/openapi-ts/test/3.0.x.test.ts index 23674654d..7cea50db1 100644 --- a/packages/openapi-ts/test/3.0.x.test.ts +++ b/packages/openapi-ts/test/3.0.x.test.ts @@ -400,6 +400,45 @@ describe(`OpenAPI ${VERSION}`, () => { }), description: 'handles non-exploded array query parameters', }, + { + config: createConfig({ + input: 'security-api-key.json', + output: 'security-api-key', + plugins: [ + { + auth: true, + name: '@hey-api/sdk', + }, + ], + }), + description: 'generates SDK functions with auth (api key)', + }, + { + config: createConfig({ + input: 'security-oauth2.json', + output: 'security-oauth2', + plugins: [ + { + auth: true, + name: '@hey-api/sdk', + }, + ], + }), + description: 'generates SDK functions with auth (oauth2)', + }, + { + config: createConfig({ + input: 'security-oauth2.json', + output: 'security-false', + plugins: [ + { + auth: false, + name: '@hey-api/sdk', + }, + ], + }), + description: 'generates SDK functions without auth', + }, { config: createConfig({ input: 'transformers-all-of.yaml', diff --git a/packages/openapi-ts/test/3.1.x.test.ts b/packages/openapi-ts/test/3.1.x.test.ts index 4eca6bf3e..a88b5a592 100644 --- a/packages/openapi-ts/test/3.1.x.test.ts +++ b/packages/openapi-ts/test/3.1.x.test.ts @@ -461,6 +461,45 @@ describe(`OpenAPI ${VERSION}`, () => { description: 'does not set oneOf composition ref model properties as required', }, + { + config: createConfig({ + input: 'security-api-key.json', + output: 'security-api-key', + plugins: [ + { + auth: true, + name: '@hey-api/sdk', + }, + ], + }), + description: 'generates SDK functions with auth (api key)', + }, + { + config: createConfig({ + input: 'security-oauth2.json', + output: 'security-oauth2', + plugins: [ + { + auth: true, + name: '@hey-api/sdk', + }, + ], + }), + description: 'generates SDK functions with auth (oauth2)', + }, + { + config: createConfig({ + input: 'security-oauth2.json', + output: 'security-false', + plugins: [ + { + auth: false, + name: '@hey-api/sdk', + }, + ], + }), + description: 'generates SDK functions without auth', + }, { config: createConfig({ input: 'transformers-all-of.yaml', diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/index.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/sdk.gen.ts new file mode 100644 index 000000000..5bae7a23b --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/sdk.gen.ts @@ -0,0 +1,20 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; +import type { GetFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const getFoo = (options?: Options) => { + return (options?.client ?? client).get({ + ...options, + security: [ + { + fn: 'apiKey', + in: 'query', + name: 'foo' + } + ], + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/types.gen.ts new file mode 100644 index 000000000..915f766f3 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-api-key/types.gen.ts @@ -0,0 +1,15 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooData = { + body?: never; + path?: never; + query?: never; + url: '/foo'; +}; + +export type GetFooResponses = { + /** + * OK + */ + 200: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/index.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/sdk.gen.ts new file mode 100644 index 000000000..e8a954bd8 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/sdk.gen.ts @@ -0,0 +1,13 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; +import type { GetFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const getFoo = (options?: Options) => { + return (options?.client ?? client).get({ + ...options, + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/types.gen.ts new file mode 100644 index 000000000..915f766f3 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-false/types.gen.ts @@ -0,0 +1,15 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooData = { + body?: never; + path?: never; + query?: never; + url: '/foo'; +}; + +export type GetFooResponses = { + /** + * OK + */ + 200: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/index.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/sdk.gen.ts new file mode 100644 index 000000000..a3425f992 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/sdk.gen.ts @@ -0,0 +1,20 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; +import type { GetFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const getFoo = (options?: Options) => { + return (options?.client ?? client).get({ + ...options, + security: [ + { + fn: 'accessToken', + in: 'header', + name: 'Authorization' + } + ], + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/types.gen.ts new file mode 100644 index 000000000..915f766f3 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/security-oauth2/types.gen.ts @@ -0,0 +1,15 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooData = { + body?: never; + path?: never; + query?: never; + url: '/foo'; +}; + +export type GetFooResponses = { + /** + * OK + */ + 200: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/index.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/sdk.gen.ts new file mode 100644 index 000000000..5bae7a23b --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/sdk.gen.ts @@ -0,0 +1,20 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; +import type { GetFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const getFoo = (options?: Options) => { + return (options?.client ?? client).get({ + ...options, + security: [ + { + fn: 'apiKey', + in: 'query', + name: 'foo' + } + ], + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/types.gen.ts new file mode 100644 index 000000000..915f766f3 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-api-key/types.gen.ts @@ -0,0 +1,15 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooData = { + body?: never; + path?: never; + query?: never; + url: '/foo'; +}; + +export type GetFooResponses = { + /** + * OK + */ + 200: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/index.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/sdk.gen.ts new file mode 100644 index 000000000..e8a954bd8 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/sdk.gen.ts @@ -0,0 +1,13 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; +import type { GetFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const getFoo = (options?: Options) => { + return (options?.client ?? client).get({ + ...options, + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/types.gen.ts new file mode 100644 index 000000000..915f766f3 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-false/types.gen.ts @@ -0,0 +1,15 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooData = { + body?: never; + path?: never; + query?: never; + url: '/foo'; +}; + +export type GetFooResponses = { + /** + * OK + */ + 200: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/index.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/index.ts new file mode 100644 index 000000000..e64537d21 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/sdk.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/sdk.gen.ts new file mode 100644 index 000000000..a3425f992 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/sdk.gen.ts @@ -0,0 +1,20 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createClient, createConfig, type Options } from '@hey-api/client-fetch'; +import type { GetFooData } from './types.gen'; + +export const client = createClient(createConfig()); + +export const getFoo = (options?: Options) => { + return (options?.client ?? client).get({ + ...options, + security: [ + { + fn: 'accessToken', + in: 'header', + name: 'Authorization' + } + ], + url: '/foo' + }); +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/types.gen.ts new file mode 100644 index 000000000..915f766f3 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/security-oauth2/types.gen.ts @@ -0,0 +1,15 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooData = { + body?: never; + path?: never; + query?: never; + url: '/foo'; +}; + +export type GetFooResponses = { + /** + * OK + */ + 200: unknown; +}; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap index cb721db62..ad10cf1b8 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/index.ts.snap @@ -2,7 +2,13 @@ import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; import type { Client, Config } from './types'; -import { createConfig, getUrl, mergeConfigs, mergeHeaders } from './utils'; +import { + createConfig, + getUrl, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils'; export const createClient = (config: Config): Client => { let _config = mergeConfigs(createConfig(), config); @@ -27,11 +33,17 @@ export const createClient = (config: Config): Client => { const opts = { ..._config, ...options, - headers: mergeHeaders( - _config.headers, - options.headers, - ) as RawAxiosRequestHeaders, + axios: options.axios ?? _config.axios ?? instance, + headers: mergeHeaders(_config.headers, options.headers), }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } @@ -41,12 +53,11 @@ export const createClient = (config: Config): Client => { url: opts.url, }); - const _axios = opts.axios || instance; - try { - const response = await _axios({ + const response = await opts.axios({ ...opts, data: opts.body, + headers: opts.headers as RawAxiosRequestHeaders, params: opts.query, url, }); diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap index 707a074b3..7fff7be39 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/types.ts.snap @@ -12,9 +12,20 @@ type OmitKeys = Pick>; export interface Config extends Omit { + /** + * Access token or a function returning access token. The resolved token will + * be added to request payload as required. + */ + accessToken?: (() => Promise) | string | undefined; + /** + * API key or a function returning API key. The resolved key will be added + * to the request payload as required. + */ + apiKey?: (() => Promise) | string | undefined; /** * Axios implementation. You can use this option to provide a custom * Axios instance. + * * @default axios */ axios?: AxiosStatic; @@ -64,6 +75,7 @@ export interface Config responseTransformer?: (data: unknown) => Promise; /** * Throw an error instead of returning it in the response? + * * @default false */ throwOnError?: ThrowOnError; @@ -87,6 +99,10 @@ export interface RequestOptions< client?: Client; path?: Record; query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; url: Url; } @@ -101,6 +117,12 @@ export type RequestResult< | (AxiosError & { data: undefined; error: TError }) >; +export interface Security { + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; +} + type MethodFn = < Data = unknown, TError = unknown, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap index 2d3e8a8ee..f171ade00 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle/client/utils.ts.snap @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -250,6 +250,53 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { return url; }; +export const getAuthToken = async ( + security: Security, + options: Pick, +): Promise => { + if (security.fn === 'accessToken') { + const token = + typeof options.accessToken === 'function' + ? await options.accessToken() + : options.accessToken; + return token ? `Bearer ${token}` : undefined; + } + + if (security.fn === 'apiKey') { + return typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey; + } +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Record; + }) => { + for (const scheme of security) { + const token = await getAuthToken(scheme, options); + + if (!token) { + continue; + } + + if (scheme.in === 'header') { + options.headers[scheme.name] = token; + } else if (scheme.in === 'query') { + if (!options.query) { + options.query = {}; + } + + options.query[scheme.name] = token; + } + + return; + } +}; + export const getUrl = ({ path, url, @@ -278,8 +325,8 @@ export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeHeaders = ( ...headers: Array['headers'] | undefined> -): Required['headers'] => { - const mergedHeaders: Required['headers'] = {}; +): Record => { + const mergedHeaders: Record = {}; for (const header of headers) { if (!header || typeof header !== 'object') { continue; @@ -289,7 +336,6 @@ export const mergeHeaders = ( for (const [key, value] of iterator) { if (value === null) { - // @ts-expect-error delete mergedHeaders[key]; } else if (Array.isArray(value)) { for (const v of value) { @@ -299,7 +345,6 @@ export const mergeHeaders = ( } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - // @ts-expect-error mergedHeaders[key] = typeof value === 'object' ? JSON.stringify(value) : (value as string); } diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap index cb721db62..ad10cf1b8 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/index.ts.snap @@ -2,7 +2,13 @@ import type { AxiosError, RawAxiosRequestHeaders } from 'axios'; import axios from 'axios'; import type { Client, Config } from './types'; -import { createConfig, getUrl, mergeConfigs, mergeHeaders } from './utils'; +import { + createConfig, + getUrl, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils'; export const createClient = (config: Config): Client => { let _config = mergeConfigs(createConfig(), config); @@ -27,11 +33,17 @@ export const createClient = (config: Config): Client => { const opts = { ..._config, ...options, - headers: mergeHeaders( - _config.headers, - options.headers, - ) as RawAxiosRequestHeaders, + axios: options.axios ?? _config.axios ?? instance, + headers: mergeHeaders(_config.headers, options.headers), }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } @@ -41,12 +53,11 @@ export const createClient = (config: Config): Client => { url: opts.url, }); - const _axios = opts.axios || instance; - try { - const response = await _axios({ + const response = await opts.axios({ ...opts, data: opts.body, + headers: opts.headers as RawAxiosRequestHeaders, params: opts.query, url, }); diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap index 707a074b3..7fff7be39 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/types.ts.snap @@ -12,9 +12,20 @@ type OmitKeys = Pick>; export interface Config extends Omit { + /** + * Access token or a function returning access token. The resolved token will + * be added to request payload as required. + */ + accessToken?: (() => Promise) | string | undefined; + /** + * API key or a function returning API key. The resolved key will be added + * to the request payload as required. + */ + apiKey?: (() => Promise) | string | undefined; /** * Axios implementation. You can use this option to provide a custom * Axios instance. + * * @default axios */ axios?: AxiosStatic; @@ -64,6 +75,7 @@ export interface Config responseTransformer?: (data: unknown) => Promise; /** * Throw an error instead of returning it in the response? + * * @default false */ throwOnError?: ThrowOnError; @@ -87,6 +99,10 @@ export interface RequestOptions< client?: Client; path?: Record; query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; url: Url; } @@ -101,6 +117,12 @@ export type RequestResult< | (AxiosError & { data: undefined; error: TError }) >; +export interface Security { + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; +} + type MethodFn = < Data = unknown, TError = unknown, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap index 2d3e8a8ee..f171ade00 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-axios-bundle_transform/client/utils.ts.snap @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -250,6 +250,53 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { return url; }; +export const getAuthToken = async ( + security: Security, + options: Pick, +): Promise => { + if (security.fn === 'accessToken') { + const token = + typeof options.accessToken === 'function' + ? await options.accessToken() + : options.accessToken; + return token ? `Bearer ${token}` : undefined; + } + + if (security.fn === 'apiKey') { + return typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey; + } +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Record; + }) => { + for (const scheme of security) { + const token = await getAuthToken(scheme, options); + + if (!token) { + continue; + } + + if (scheme.in === 'header') { + options.headers[scheme.name] = token; + } else if (scheme.in === 'query') { + if (!options.query) { + options.query = {}; + } + + options.query[scheme.name] = token; + } + + return; + } +}; + export const getUrl = ({ path, url, @@ -278,8 +325,8 @@ export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeHeaders = ( ...headers: Array['headers'] | undefined> -): Required['headers'] => { - const mergedHeaders: Required['headers'] = {}; +): Record => { + const mergedHeaders: Record = {}; for (const header of headers) { if (!header || typeof header !== 'object') { continue; @@ -289,7 +336,6 @@ export const mergeHeaders = ( for (const [key, value] of iterator) { if (value === null) { - // @ts-expect-error delete mergedHeaders[key]; } else if (Array.isArray(value)) { for (const v of value) { @@ -299,7 +345,6 @@ export const mergeHeaders = ( } else if (value !== undefined) { // assume object headers are meant to be JSON stringified, i.e. their // content value in OpenAPI specification is 'application/json' - // @ts-expect-error mergedHeaders[key] = typeof value === 'object' ? JSON.stringify(value) : (value as string); } diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/index.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/index.ts.snap index 356efd022..d96d8344c 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/index.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/index.ts.snap @@ -1,12 +1,12 @@ import type { Client, Config, RequestOptions } from './types'; import { + buildUrl, createConfig, createInterceptors, - createQuerySerializer, getParseAs, - getUrl, mergeConfigs, mergeHeaders, + setAuthParams, } from './utils'; type ReqInit = Omit & { @@ -24,20 +24,6 @@ export const createClient = (config: Config = {}): Client => { return getConfig(); }; - const buildUrl: Client['buildUrl'] = (options) => { - const url = getUrl({ - baseUrl: options.baseUrl ?? '', - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - return url; - }; - const interceptors = createInterceptors< Request, Response, @@ -53,6 +39,14 @@ export const createClient = (config: Config = {}): Client => { fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } @@ -74,8 +68,7 @@ export const createClient = (config: Config = {}): Client => { request = await fn(request, opts); } - const _fetch = opts.fetch!; - let response = await _fetch(request); + let response = await opts.fetch(request); for (const fn of interceptors.response._fns) { response = await fn(response, request, opts); diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/types.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/types.ts.snap index 737ecf57b..0c914db6c 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/types.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/types.ts.snap @@ -9,8 +9,19 @@ type OmitKeys = Pick>; export interface Config extends Omit { + /** + * Access token or a function returning access token. The resolved token + * will be added to request headers where it's required. + */ + accessToken?: (() => Promise) | string | undefined; + /** + * API key or a function returning API key. The resolved key will be added + * to the request payload as required. + */ + apiKey?: (() => Promise) | string | undefined; /** * Base URL for all requests made by this client. + * * @default '' */ baseUrl?: string; @@ -22,6 +33,7 @@ export interface Config /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. + * * @default globalThis.fetch */ fetch?: (request: Request) => ReturnType; @@ -63,6 +75,7 @@ export interface Config * will infer the appropriate method from the `Content-Type` response header. * You can override this behavior with any of the {@link Body} methods. * Select `stream` if you don't want to parse response data at all. + * * @default 'auto' */ parseAs?: Exclude | 'auto' | 'stream'; @@ -82,6 +95,7 @@ export interface Config responseTransformer?: (data: unknown) => Promise; /** * Throw an error instead of returning it in the response? + * * @default false */ throwOnError?: ThrowOnError; @@ -110,6 +124,10 @@ export interface RequestOptions< client?: Client; path?: Record; query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; url: Url; } @@ -133,6 +151,12 @@ export type RequestResult< } >; +export interface Security { + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; +} + type MethodFn = < Data = unknown, TError = unknown, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/utils.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/utils.ts.snap index 19a2e4024..8b43a99de 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/utils.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle/client/utils.ts.snap @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Client, Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -358,6 +358,67 @@ export const getParseAs = ( } }; +export const getAuthToken = async ( + security: Security, + options: Pick, +): Promise => { + if (security.fn === 'accessToken') { + const token = + typeof options.accessToken === 'function' + ? await options.accessToken() + : options.accessToken; + return token ? `Bearer ${token}` : undefined; + } + + if (security.fn === 'apiKey') { + return typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey; + } +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const scheme of security) { + const token = await getAuthToken(scheme, options); + + if (!token) { + continue; + } + + if (scheme.in === 'header') { + options.headers.set(scheme.name, token); + } else if (scheme.in === 'query') { + if (!options.query) { + options.query = {}; + } + + options.query[scheme.name] = token; + } + + return; + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + baseUrl: options.baseUrl ?? '', + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + export const getUrl = ({ baseUrl, path, @@ -397,7 +458,7 @@ export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeHeaders = ( ...headers: Array['headers'] | undefined> -) => { +): Headers => { const mergedHeaders = new Headers(); for (const header of headers) { if (!header || typeof header !== 'object') { diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/index.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/index.ts.snap index 356efd022..d96d8344c 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/index.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/index.ts.snap @@ -1,12 +1,12 @@ import type { Client, Config, RequestOptions } from './types'; import { + buildUrl, createConfig, createInterceptors, - createQuerySerializer, getParseAs, - getUrl, mergeConfigs, mergeHeaders, + setAuthParams, } from './utils'; type ReqInit = Omit & { @@ -24,20 +24,6 @@ export const createClient = (config: Config = {}): Client => { return getConfig(); }; - const buildUrl: Client['buildUrl'] = (options) => { - const url = getUrl({ - baseUrl: options.baseUrl ?? '', - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - return url; - }; - const interceptors = createInterceptors< Request, Response, @@ -53,6 +39,14 @@ export const createClient = (config: Config = {}): Client => { fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, headers: mergeHeaders(_config.headers, options.headers), }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + if (opts.body && opts.bodySerializer) { opts.body = opts.bodySerializer(opts.body); } @@ -74,8 +68,7 @@ export const createClient = (config: Config = {}): Client => { request = await fn(request, opts); } - const _fetch = opts.fetch!; - let response = await _fetch(request); + let response = await opts.fetch(request); for (const fn of interceptors.response._fns) { response = await fn(response, request, opts); diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/types.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/types.ts.snap index 737ecf57b..0c914db6c 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/types.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/types.ts.snap @@ -9,8 +9,19 @@ type OmitKeys = Pick>; export interface Config extends Omit { + /** + * Access token or a function returning access token. The resolved token + * will be added to request headers where it's required. + */ + accessToken?: (() => Promise) | string | undefined; + /** + * API key or a function returning API key. The resolved key will be added + * to the request payload as required. + */ + apiKey?: (() => Promise) | string | undefined; /** * Base URL for all requests made by this client. + * * @default '' */ baseUrl?: string; @@ -22,6 +33,7 @@ export interface Config /** * Fetch API implementation. You can use this option to provide a custom * fetch instance. + * * @default globalThis.fetch */ fetch?: (request: Request) => ReturnType; @@ -63,6 +75,7 @@ export interface Config * will infer the appropriate method from the `Content-Type` response header. * You can override this behavior with any of the {@link Body} methods. * Select `stream` if you don't want to parse response data at all. + * * @default 'auto' */ parseAs?: Exclude | 'auto' | 'stream'; @@ -82,6 +95,7 @@ export interface Config responseTransformer?: (data: unknown) => Promise; /** * Throw an error instead of returning it in the response? + * * @default false */ throwOnError?: ThrowOnError; @@ -110,6 +124,10 @@ export interface RequestOptions< client?: Client; path?: Record; query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; url: Url; } @@ -133,6 +151,12 @@ export type RequestResult< } >; +export interface Security { + fn: 'accessToken' | 'apiKey'; + in: 'header' | 'query'; + name: string; +} + type MethodFn = < Data = unknown, TError = unknown, diff --git a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/utils.ts.snap b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/utils.ts.snap index 19a2e4024..8b43a99de 100644 --- a/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/utils.ts.snap +++ b/packages/openapi-ts/test/__snapshots__/test/generated/v3-hey-api-client-fetch-bundle_transform/client/utils.ts.snap @@ -1,4 +1,4 @@ -import type { Config } from './types'; +import type { Client, Config, RequestOptions, Security } from './types'; interface PathSerializer { path: Record; @@ -358,6 +358,67 @@ export const getParseAs = ( } }; +export const getAuthToken = async ( + security: Security, + options: Pick, +): Promise => { + if (security.fn === 'accessToken') { + const token = + typeof options.accessToken === 'function' + ? await options.accessToken() + : options.accessToken; + return token ? `Bearer ${token}` : undefined; + } + + if (security.fn === 'apiKey') { + return typeof options.apiKey === 'function' + ? await options.apiKey() + : options.apiKey; + } +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const scheme of security) { + const token = await getAuthToken(scheme, options); + + if (!token) { + continue; + } + + if (scheme.in === 'header') { + options.headers.set(scheme.name, token); + } else if (scheme.in === 'query') { + if (!options.query) { + options.query = {}; + } + + options.query[scheme.name] = token; + } + + return; + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => { + const url = getUrl({ + baseUrl: options.baseUrl ?? '', + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + return url; +}; + export const getUrl = ({ baseUrl, path, @@ -397,7 +458,7 @@ export const mergeConfigs = (a: Config, b: Config): Config => { export const mergeHeaders = ( ...headers: Array['headers'] | undefined> -) => { +): Headers => { const mergedHeaders = new Headers(); for (const header of headers) { if (!header || typeof header !== 'object') { diff --git a/packages/openapi-ts/test/sample.cjs b/packages/openapi-ts/test/sample.cjs index d93ab50c9..f62dd69c7 100644 --- a/packages/openapi-ts/test/sample.cjs +++ b/packages/openapi-ts/test/sample.cjs @@ -5,15 +5,15 @@ const main = async () => { const config = { client: { // bundle: true, - // name: '@hey-api/client-axios', - name: '@hey-api/client-fetch', + name: '@hey-api/client-axios', + // name: '@hey-api/client-fetch', }, experimentalParser: true, input: { exclude: '^#/components/schemas/ModelWithCircularReference$', // include: // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', - path: './test/spec/3.0.x/full.json', + path: './test/spec/3.0.x/security-api-key.json', // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', // path: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', }, @@ -35,8 +35,9 @@ const main = async () => { }, { // asClass: true, + // auth: false, // include... - // name: '@hey-api/sdk', + name: '@hey-api/sdk', // operationId: false, // serviceNameBuilder: '^Parameters', }, @@ -50,7 +51,7 @@ const main = async () => { enums: 'javascript', // exportInlineEnums: true, identifierCase: 'preserve', - // name: '@hey-api/typescript', + name: '@hey-api/typescript', // tree: true, }, { @@ -60,7 +61,7 @@ const main = async () => { // name: '@tanstack/vue-query', }, { - name: 'zod', + // name: 'zod', }, ], // useOptions: false, diff --git a/packages/openapi-ts/test/spec/3.0.x/security-api-key.json b/packages/openapi-ts/test/spec/3.0.x/security-api-key.json new file mode 100644 index 000000000..e5246b80e --- /dev/null +++ b/packages/openapi-ts/test/spec/3.0.x/security-api-key.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "OpenAPI 3.0.4 security api key example", + "version": "1" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "foo": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "foo": { + "in": "query", + "name": "foo", + "type": "apiKey" + } + } + } +} diff --git a/packages/openapi-ts/test/spec/3.0.x/security-oauth2.json b/packages/openapi-ts/test/spec/3.0.x/security-oauth2.json new file mode 100644 index 000000000..5fda49742 --- /dev/null +++ b/packages/openapi-ts/test/spec/3.0.x/security-oauth2.json @@ -0,0 +1,36 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "OpenAPI 3.0.4 security oauth2 example", + "version": "1" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "foo": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "foo": { + "flows": { + "password": { + "scopes": {}, + "tokenUrl": "/" + } + }, + "type": "oauth2" + } + } + } +} diff --git a/packages/openapi-ts/test/spec/3.1.x/security-api-key.json b/packages/openapi-ts/test/spec/3.1.x/security-api-key.json new file mode 100644 index 000000000..80698550a --- /dev/null +++ b/packages/openapi-ts/test/spec/3.1.x/security-api-key.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "OpenAPI 3.1.1 security api key example", + "version": "1" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "foo": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "foo": { + "in": "query", + "name": "foo", + "type": "apiKey" + } + } + } +} diff --git a/packages/openapi-ts/test/spec/3.1.x/security-oauth2.json b/packages/openapi-ts/test/spec/3.1.x/security-oauth2.json new file mode 100644 index 000000000..469959d9b --- /dev/null +++ b/packages/openapi-ts/test/spec/3.1.x/security-oauth2.json @@ -0,0 +1,36 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "OpenAPI 3.1.1 security oauth2 example", + "version": "1" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "foo": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "foo": { + "flows": { + "password": { + "scopes": {}, + "tokenUrl": "/" + } + }, + "type": "oauth2" + } + } + } +}