Skip to content

Commit

Permalink
feat: support oauth2 and apiKey security schemes
Browse files Browse the repository at this point in the history
  • Loading branch information
mrlubos committed Dec 9, 2024
1 parent eae21a2 commit 646064d
Show file tree
Hide file tree
Showing 59 changed files with 1,589 additions and 128 deletions.
7 changes: 7 additions & 0 deletions .changeset/strong-countries-fail.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/openapi-ts/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface Config {
name: 'my-plugin';
/**
* Name of the generated file.
*
* @default 'my-plugin'
*/
output?: string;
Expand Down
175 changes: 175 additions & 0 deletions packages/client-axios/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<any, unknown> = {};
const query: Record<any, unknown> = {};
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<any, unknown> = {};
const query: Record<any, unknown> = {};
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<any, unknown> = {};
const query: Record<any, unknown> = {};
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<any, unknown> = {};
const query: Record<any, unknown> = {};
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');
});
});
27 changes: 19 additions & 8 deletions packages/client-axios/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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,
});
Expand Down
22 changes: 22 additions & 0 deletions packages/client-axios/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,20 @@ type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;

export interface Config<ThrowOnError extends boolean = boolean>
extends Omit<CreateAxiosDefaults, 'headers'> {
/**
* Access token or a function returning access token. The resolved token will
* be added to request payload as required.
*/
accessToken?: (() => Promise<string | undefined>) | 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>) | string | undefined;
/**
* Axios implementation. You can use this option to provide a custom
* Axios instance.
*
* @default axios
*/
axios?: AxiosStatic;
Expand Down Expand Up @@ -64,6 +75,7 @@ export interface Config<ThrowOnError extends boolean = boolean>
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: ThrowOnError;
Expand All @@ -87,6 +99,10 @@ export interface RequestOptions<
client?: Client;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Security>;
url: Url;
}

Expand All @@ -101,6 +117,12 @@ export type RequestResult<
| (AxiosError<TError> & { data: undefined; error: TError })
>;

export interface Security {
fn: 'accessToken' | 'apiKey';
in: 'header' | 'query';
name: string;
}

type MethodFn = <
Data = unknown,
TError = unknown,
Expand Down
55 changes: 50 additions & 5 deletions packages/client-axios/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Config } from './types';
import type { Config, RequestOptions, Security } from './types';

interface PathSerializer {
path: Record<string, unknown>;
Expand Down Expand Up @@ -250,6 +250,53 @@ const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
return url;
};

export const getAuthToken = async (
security: Security,
options: Pick<RequestOptions, 'accessToken' | 'apiKey'>,
): Promise<string | undefined> => {
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<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'accessToken' | 'apiKey' | 'query'> & {
headers: Record<any, unknown>;
}) => {
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,
Expand Down Expand Up @@ -278,8 +325,8 @@ export const mergeConfigs = (a: Config, b: Config): Config => {

export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Required<Config>['headers'] => {
const mergedHeaders: Required<Config>['headers'] = {};
): Record<any, unknown> => {
const mergedHeaders: Record<any, unknown> = {};
for (const header of headers) {
if (!header || typeof header !== 'object') {
continue;
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down
7 changes: 0 additions & 7 deletions packages/client-axios/test/index.test.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { createClient } from '../src/index';
import { createClient } from '../index';

describe('buildUrl', () => {
const client = createClient();
Expand Down
Loading

0 comments on commit 646064d

Please sign in to comment.