Skip to content
This repository has been archived by the owner on May 22, 2023. It is now read-only.

Commit

Permalink
Fix data, error sometimes returning undefined
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Apr 6, 2023
1 parent 54bf90e commit 27c149c
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 88 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-readers-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openapi-fetch': patch
---

Fix data, error sometimes returning undefined
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@
"build": "npm run build:clean && npm run build:ts",
"build:clean": "del dist",
"build:ts": "tsc -p tsconfig.build.json",
"lint": "npm run lint:ts && npm run lint:js",
"lint": "npm run lint:js",
"lint:js": "npx prettier --check \"{src,test}/**/*\"",
"lint:ts": "tsc --noEmit",
"test": "vitest run",
"test": "npm run test:ts && npm run test:js",
"test:js": "vitest run",
"test:ts": "tsc --noEmit",
"prepare": "openapi-typescript test/v1.yaml -o test/v1.d.ts",
"prepublish": "npm run prepare && npm run build",
"version": "npm run prepare && npm run build"
Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,51 @@ describe('createClient', () => {
expect(client).toHaveProperty('trace');
});

it('marks data as undefined, but never both', async () => {
const client = createClient<paths>();

// data
fetchMocker.mockResponseOnce(JSON.stringify(['one', 'two', 'three']));
const dataRes = await client.get('/string-array', {});

// … is initially possibly undefined
// @ts-expect-error
expect(dataRes.data[0]).toBe('one');

// … is present if error is undefined
if (!dataRes.error) {
expect(dataRes.data[0]).toBe('one');
}

// … means data is undefined
if (dataRes.data) {
// @ts-expect-error
expect(() => dataRes.error.message).toThrow();
}

// error
fetchMocker.mockResponseOnce(() => ({
status: 500,
body: JSON.stringify({ status: '500', message: 'Something went wrong' }),
}));
const errorRes = await client.get('/string-array', {});

// … is initially possibly undefined
// @ts-expect-error
expect(errorRes.error.message).toBe('Something went wrong');

// … is present if error is undefined
if (!errorRes.data) {
expect(errorRes.error.message).toBe('Something went wrong');
}

// … means data is undefined
if (errorRes.error) {
// @ts-expect-error
expect(() => errorRes.data[0]).toThrow();
}
});

it('respects baseUrl', async () => {
const client = createClient<paths>({ baseUrl: 'https://myapi.com/v1' });
fetchMocker.mockResponse(JSON.stringify({ message: 'OK' }));
Expand Down
116 changes: 58 additions & 58 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,70 +18,46 @@ export type Method = (typeof methods)[number];

type TruncatedResponse = Omit<Response, 'arrayBuffer' | 'blob' | 'body' | 'clone' | 'formData' | 'json' | 'text'>;
/** Infer request/response from content type */
type UnwrapNonNullable<T> = T extends {
type Unwrap<T> = T extends {
content: { 'application/json': any };
}
? T['content']['application/json']
: T extends { content: { '*/*': any } }
? T['content']['*/*']
: T;
type Unwrap<T> = T extends NonNullable<T> | undefined ? UnwrapNonNullable<NonNullable<T>> | undefined : UnwrapNonNullable<T>;

export default function createClient<T>(defaultOptions?: ClientOptions) {
const defaultHeaders = new Headers({
...DEFAULT_HEADERS,
...(defaultOptions?.headers ?? {}),
});

async function coreFetch<U extends keyof T, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>): Promise<FetchResponse<U, M>> {
let { headers, body, params = {}, ...init } = options || {};
// URL
let finalURL = `${defaultOptions?.baseUrl ?? ''}${url as string}`;
const { path, query } = (params as BaseParams | undefined) ?? {};
if (path) for (const [k, v] of Object.entries(path)) finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(`${v}`.trim()));
if (query) finalURL = `${finalURL}?${new URLSearchParams(query as any).toString()}`;
// headers
const baseHeaders = new Headers(defaultHeaders); // clone defaults (don’t overwrite!)
const headerOverrides = new Headers(headers);
for (const [k, v] of headerOverrides.entries()) {
if (v === undefined || v === null) baseHeaders.delete(k); // allow `undefined` | `null` to erase value
else baseHeaders.set(k, v);
}
// fetch!
const res = await fetch(finalURL, {
redirect: 'follow',
...defaultOptions,
...init,
headers: baseHeaders,
body: typeof body === 'string' ? body : JSON.stringify(body),
});
const response: TruncatedResponse = {
bodyUsed: res.bodyUsed,
headers: res.headers,
ok: res.ok,
redirected: res.redirected,
status: res.status,
statusText: res.statusText,
type: res.type,
url: res.url,
};
return res.ok ? { data: await res.json(), response } : { error: await res.json(), response };
}

/** Gets a union of paths which have method */
type PathsWith<M extends Method> = {
[Path in keyof T]: T[Path] extends { [K in M]: unknown } ? Path : never;
}[keyof T];

type FetchResponse<T> =
| {
data: T extends { responses: any } ? NonNullable<Unwrap<Success<T['responses']>>> : unknown;
error?: never;
response: TruncatedResponse;
}
| {
data?: never;
error: T extends { responses: any } ? NonNullable<Unwrap<Error<T['responses']>>> : unknown;
response: TruncatedResponse;
};

type PathParams<T> = T extends { parameters: any } ? { params: T['parameters'] } : { params?: BaseParams };
type MethodParams<T> = T extends {
parameters: any;
}
? { params: T['parameters'] }
: { params?: BaseParams };
type Params<T> = PathParams<T> & MethodParams<T>;
type Body<T> = T extends { requestBody: any } ? { body: Unwrap<T['requestBody']> } : { body?: never };
type FetchOptions<U extends keyof T, M extends keyof T[U]> = Params<T[U][M]> & Body<T[U][M]> & Omit<RequestInit, 'body'>;
type RequestBody<T> = T extends { requestBody: any } ? { body: Unwrap<T['requestBody']> } : { body?: never };
type FetchOptions<T> = Params<T> & RequestBody<T> & Omit<RequestInit, 'body'>;
type Success<T> = T extends { 200: any } ? T[200] : T extends { 201: any } ? T[201] : T extends { 202: any } ? T[202] : T extends { default: any } ? T['default'] : unknown;
type Error<T> = T extends { 500: any }
? T[500]
Expand Down Expand Up @@ -126,49 +102,73 @@ export default function createClient<T>(defaultOptions?: ClientOptions) {
: T extends { default: any }
? T['default']
: unknown;
type FetchResponse<U extends keyof T, M extends keyof T[U]> =
| {
data: T[U][M] extends { responses: any } ? Unwrap<Success<T[U][M]['responses']>> : unknown;
error?: never;
response: TruncatedResponse;
}
| {
data?: never;
error: T[U][M] extends { responses: any } ? Unwrap<Error<T[U][M]['responses']>> : unknown;
response: TruncatedResponse;
};

async function coreFetch<U extends keyof T, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>): Promise<FetchResponse<T[U][M]>> {
let { headers, body, params = {}, ...init } = options || {};
// URL
let finalURL = `${defaultOptions?.baseUrl ?? ''}${url as string}`;
const { path, query } = (params as BaseParams | undefined) ?? {};
if (path) for (const [k, v] of Object.entries(path)) finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(`${v}`.trim()));
if (query) finalURL = `${finalURL}?${new URLSearchParams(query as any).toString()}`;
// headers
const baseHeaders = new Headers(defaultHeaders); // clone defaults (don’t overwrite!)
const headerOverrides = new Headers(headers);
for (const [k, v] of headerOverrides.entries()) {
if (v === undefined || v === null) baseHeaders.delete(k); // allow `undefined` | `null` to erase value
else baseHeaders.set(k, v);
}
// fetch!
const res = await fetch(finalURL, {
redirect: 'follow',
...defaultOptions,
...init,
headers: baseHeaders,
body: typeof body === 'string' ? body : JSON.stringify(body),
});
const response: TruncatedResponse = {
bodyUsed: res.bodyUsed,
headers: res.headers,
ok: res.ok,
redirected: res.redirected,
status: res.status,
statusText: res.statusText,
type: res.type,
url: res.url,
};
return res.ok ? { data: await res.json(), response } : { error: await res.json(), response };
}

return {
/** Call a GET endpoint */
async get<U extends PathsWith<'get'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async get<U extends PathsWith<'get'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'GET' });
},
/** Call a PUT endpoint */
async put<U extends PathsWith<'put'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async put<U extends PathsWith<'put'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'PUT' });
},
/** Call a POST endpoint */
async post<U extends PathsWith<'post'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async post<U extends PathsWith<'post'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'POST' });
},
/** Call a DELETE endpoint */
async del<U extends PathsWith<'delete'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async del<U extends PathsWith<'delete'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'DELETE' });
},
/** Call a OPTIONS endpoint */
async options<U extends PathsWith<'options'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async options<U extends PathsWith<'options'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'OPTIONS' });
},
/** Call a HEAD endpoint */
async head<U extends PathsWith<'head'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async head<U extends PathsWith<'head'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'HEAD' });
},
/** Call a PATCH endpoint */
async patch<U extends PathsWith<'patch'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async patch<U extends PathsWith<'patch'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'PATCH' });
},
/** Call a TRACE endpoint */
async trace<U extends PathsWith<'trace'>, M extends keyof T[U]>(url: U, options: FetchOptions<U, M>) {
async trace<U extends PathsWith<'trace'>, M extends keyof T[U]>(url: U, options: FetchOptions<T[U][M]>) {
return coreFetch(url, { ...options, method: 'TRACE' });
},
};
Expand Down
28 changes: 21 additions & 7 deletions test/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export interface paths {
};
};
};
"/string-array": {
get: {
responses: {
200: components["responses"]["StringArray"];
500: components["responses"]["Error"];
};
};
};
"/anyMethod": {
get: {
responses: {
Expand Down Expand Up @@ -141,6 +149,7 @@ export interface components {
body: string;
publish_date?: number;
};
StringArray: (string)[];
User: {
email: string;
age?: number;
Expand All @@ -162,9 +171,12 @@ export interface components {
};
};
};
User: {
Error: {
content: {
"application/json": components["schemas"]["User"];
"application/json": {
code: number;
message: string;
};
};
};
PostDelete: {
Expand All @@ -179,12 +191,14 @@ export interface components {
"application/json": components["schemas"]["Post"];
};
};
Error: {
StringArray: {
content: {
"application/json": {
code: number;
message: string;
};
"application/json": components["schemas"]["StringArray"];
};
};
User: {
content: {
"application/json": components["schemas"]["User"];
};
};
};
Expand Down
Loading

0 comments on commit 27c149c

Please sign in to comment.