Skip to content

Commit

Permalink
Update openapi-fetch types
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Oct 5, 2023
1 parent cc8817e commit cdb1d32
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/beige-ligers-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-fetch": patch
---

Fix GET requests requiring 2nd param when it’s not needed
5 changes: 5 additions & 0 deletions .changeset/rich-poems-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-typescript": minor
---

**Feature**: Added debugger that lets you profile performance and see more in-depth messages
2 changes: 0 additions & 2 deletions docs/src/content/docs/openapi-fetch/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,6 @@ Next, generate TypeScript types from your OpenAPI schema using openapi-typescrip
npx openapi-typescript ./path/to/api/v1.yaml -o ./src/lib/api/v1.d.ts
```

> ⚠️ Be sure to <a href="https://redocly.com/docs/cli/commands/lint/" target="_blank" rel="noopener noreferrer">validate your schemas</a>! openapi-typescript will err on invalid schemas.
Lastly, be sure to **run typechecking** in your project. This can be done by adding `tsc --noEmit` to your <a href="https://docs.npmjs.com/cli/v9/using-npm/scripts" target="_blank" rel="noopener noreferrer">npm scripts</a> like so:

```json
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi-fetch/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("client", () => {
status: 200,
body: JSON.stringify(["one", "two", "three"]),
});
const dataRes = await client.GET("/string-array", {});
const dataRes = await client.GET("/string-array");

// … is initially possibly undefined
// @ts-expect-error
Expand All @@ -73,7 +73,7 @@ describe("client", () => {
status: 500,
body: JSON.stringify({ code: 500, message: "Something went wrong" }),
});
const errorRes = await client.GET("/string-array", {});
const errorRes = await client.GET("/string-array");

// … is initially possibly undefined
// @ts-expect-error
Expand Down
63 changes: 43 additions & 20 deletions packages/openapi-fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type {
const DEFAULT_HEADERS = {
"Content-Type": "application/json",
};
const TRAILING_SLASH_RE = /\/*$/;

// Note: though "any" is considered bad practice in general, this library relies
// on "any" for type inference only it can give. Same goes for the "{}" type.
Expand All @@ -32,28 +31,46 @@ interface ClientOptions extends Omit<RequestInit, "headers"> {
// headers override to make typing friendlier
headers?: HeadersOptions;
}

export type HeadersOptions =
| HeadersInit
| Record<string, string | number | boolean | null | undefined>;

export type QuerySerializer<T> = (
query: T extends { parameters: any }
? NonNullable<T["parameters"]["query"]>
: Record<string, unknown>,
) => string;

export type BodySerializer<T> = (body: OperationRequestBodyContent<T>) => any;

export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream";

export interface DefaultParamsOption {
params?: { query?: Record<string, unknown> };
}

export interface EmptyParameters {
query?: never;
header?: never;
path?: never;
cookie?: never;
}

export type ParamsOption<T> = T extends { parameters: any }
? { params: NonNullable<T["parameters"]> }
? T["parameters"] extends EmptyParameters
? DefaultParamsOption
: { params: NonNullable<T["parameters"]> }
: DefaultParamsOption;

export type RequestBodyOption<T> = OperationRequestBodyContent<T> extends never
? { body?: never }
: undefined extends OperationRequestBodyContent<T>
? { body?: OperationRequestBodyContent<T> }
: { body: OperationRequestBodyContent<T> };

export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body">;

export type FetchResponse<T> =
| {
data: FilterKeys<SuccessResponse<ResponseObjectMap<T>>, MediaType>;
Expand All @@ -65,6 +82,7 @@ export type FetchResponse<T> =
error: FilterKeys<ErrorResponse<ResponseObjectMap<T>>, MediaType>;
response: Response;
};

export type RequestOptions<T> = ParamsOption<T> &
RequestBodyOption<T> & {
querySerializer?: QuerySerializer<T>;
Expand All @@ -81,6 +99,10 @@ export default function createClient<Paths extends {}>(
bodySerializer: globalBodySerializer,
...options
} = clientOptions;
let baseUrl = options.baseUrl ?? "";
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1);
}

async function coreFetch<P extends keyof Paths, M extends HttpMethod>(
url: P,
Expand All @@ -98,7 +120,7 @@ export default function createClient<Paths extends {}>(

// URL
const finalURL = createFinalURL(url as string, {
baseUrl: options.baseUrl,
baseUrl,
params,
querySerializer,
});
Expand Down Expand Up @@ -159,13 +181,20 @@ export default function createClient<Paths extends {}>(
return { error, response: response as any };
}

type GetPaths = PathsWithMethod<Paths, "get">;
type GetFetchOptions<P extends GetPaths> = FetchOptions<
FilterKeys<Paths[P], "get">
>;

return {
/** Call a GET endpoint */
async GET<P extends PathsWithMethod<Paths, "get">>(
async GET<P extends GetPaths>(
url: P,
init: FetchOptions<FilterKeys<Paths[P], "get">>,
...init: GetFetchOptions<P> extends DefaultParamsOption // little hack to allow the 2nd param to be omitted if nothing is required (only for GET)
? [GetFetchOptions<P>?]
: [GetFetchOptions<P>]
) {
return coreFetch<P, "get">(url, { ...init, method: "GET" } as any);
return coreFetch<P, "get">(url, { ...init[0], method: "GET" } as any);
},
/** Call a PUT endpoint */
async PUT<P extends PathsWithMethod<Paths, "put">>(
Expand Down Expand Up @@ -245,26 +274,20 @@ export function defaultBodySerializer<T>(body: T): string {

/** Construct URL string from baseUrl and handle path and query params */
export function createFinalURL<O>(
url: string,
pathname: string,
options: {
baseUrl?: string;
baseUrl: string;
params: { query?: Record<string, unknown>; path?: Record<string, unknown> };
querySerializer: QuerySerializer<O>;
},
): string {
let finalURL = `${
options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""
}${url as string}`;
if (options.params.path) {
for (const [k, v] of Object.entries(options.params.path)) {
finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v)));
}
let finalURL = `${options.baseUrl}${pathname}`;
for (const [k, v] of Object.entries(options.params.path ?? {})) {
finalURL = finalURL.replace(`{${k}}`, encodeURIComponent(String(v)));
}
if (options.params.query) {
const search = options.querySerializer(options.params.query as any);
if (search) {
finalURL += `?${search}`;
}
const search = options.querySerializer((options.params.query as any) ?? {});
if (search) {
finalURL += `?${search}`;
}
return finalURL;
}
Expand Down
60 changes: 48 additions & 12 deletions packages/openapi-typescript-helpers/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

// HTTP types

export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace";
export type HttpMethod =
| "get"
| "put"
| "post"
| "delete"
| "options"
| "head"
| "patch"
| "trace";
/** 2XX statuses */
export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX";
// prettier-ignore
Expand All @@ -12,8 +20,15 @@ export type ErrorStatus = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 |
// OpenAPI type helpers

/** Given an OpenAPI **Paths Object**, find all paths that have the given method */
export type PathsWithMethod<Paths extends Record<string, PathItemObject>, PathnameMethod extends HttpMethod> = {
[Pathname in keyof Paths]: Paths[Pathname] extends { [K in PathnameMethod]: any } ? Pathname : never;
export type PathsWithMethod<
Paths extends Record<string, PathItemObject>,
PathnameMethod extends HttpMethod,
> = {
[Pathname in keyof Paths]: Paths[Pathname] extends {
[K in PathnameMethod]: any;
}
? Pathname
: never;
}[keyof Paths];
/** DO NOT USE! Only used only for OperationObject type inference */
export interface OperationObject {
Expand All @@ -23,27 +38,48 @@ export interface OperationObject {
responses: any;
}
/** Internal helper used in PathsWithMethod */
export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any };
export type PathItemObject = {
[M in HttpMethod]: OperationObject;
} & { parameters?: any };
/** Return `responses` for an Operation Object */
export type ResponseObjectMap<T> = T extends { responses: any } ? T["responses"] : unknown;
export type ResponseObjectMap<T> = T extends { responses: any }
? T["responses"]
: unknown;
/** Return `content` for a Response Object */
export type ResponseContent<T> = T extends { content: any } ? T["content"] : unknown;
export type ResponseContent<T> = T extends { content: any }
? T["content"]
: unknown;
/** Return `requestBody` for an Operation Object */
export type OperationRequestBody<T> = T extends { requestBody?: any } ? T["requestBody"] : never;
export type OperationRequestBody<T> = T extends { requestBody?: any }
? T["requestBody"]
: never;
/** Internal helper used in OperationRequestBodyContent */
export type OperationRequestBodyMediaContent<T> = undefined extends OperationRequestBody<T> ? FilterKeys<NonNullable<OperationRequestBody<T>>, "content"> | undefined : FilterKeys<OperationRequestBody<T>, "content">;
export type OperationRequestBodyMediaContent<T> =
undefined extends OperationRequestBody<T>
? FilterKeys<NonNullable<OperationRequestBody<T>>, "content"> | undefined
: FilterKeys<OperationRequestBody<T>, "content">;
/** Return first `content` from a Request Object Mapping, allowing any media type */
export type OperationRequestBodyContent<T> = FilterKeys<OperationRequestBodyMediaContent<T>, MediaType> extends never
? FilterKeys<NonNullable<OperationRequestBodyMediaContent<T>>, MediaType> | undefined
export type OperationRequestBodyContent<T> = FilterKeys<
OperationRequestBodyMediaContent<T>,
MediaType
> extends never
?
| FilterKeys<NonNullable<OperationRequestBodyMediaContent<T>>, MediaType>
| undefined
: FilterKeys<OperationRequestBodyMediaContent<T>, MediaType>;
/** Return first 2XX response from a Response Object Map */
export type SuccessResponse<T> = FilterKeys<FilterKeys<T, OkStatus>, "content">;
/** Return first 5XX or 4XX response (in that order) from a Response Object Map */
export type ErrorResponse<T> = FilterKeys<FilterKeys<T, ErrorStatus>, "content">;
export type ErrorResponse<T> = FilterKeys<
FilterKeys<T, ErrorStatus>,
"content"
>;

// Generic TS utils

/** Find first match of multiple keys */
export type FilterKeys<Obj, Matchers> = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj];
export type FilterKeys<Obj, Matchers> = {
[K in keyof Obj]: K extends Matchers ? Obj[K] : never;
}[keyof Obj];
/** Return any `[string]/[string]` media type (important because openapi-fetch allows any content response, not just JSON-like) */
export type MediaType = `${string}/${string}`;

0 comments on commit cdb1d32

Please sign in to comment.