Skip to content

Commit 5939e20

Browse files
authored
Add openapi-typescript-helpers package (#1300)
* Add openapi-typescript-helpers package * Add add’l package.json info
1 parent 067350b commit 5939e20

File tree

14 files changed

+169
-56
lines changed

14 files changed

+169
-56
lines changed

.changeset/tiny-waves-help.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-fetch": patch
3+
---
4+
5+
Use openapi-typescript-helpers package for types

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
"build:openapi-fetch": "cd packages/openapi-fetch && pnpm run build",
1414
"lint": "run-p -s lint:*",
1515
"lint:openapi-typescript": "cd packages/openapi-typescript && pnpm run lint",
16+
"lint:openapi-typescript-helpers": "cd packages/openapi-typescript-helpers && pnpm run lint",
1617
"lint:openapi-fetch": "cd packages/openapi-fetch && pnpm run lint",
1718
"test": "run-p -s test:*",
1819
"test:openapi-typescript": "cd packages/openapi-typescript && pnpm test",
20+
"test:openapi-typescript-helpers": "cd packages/openapi-typescript-helpers && pnpm test",
1921
"test:openapi-fetch": "cd packages/openapi-fetch && pnpm test",
2022
"version": "pnpm run build && changeset version && pnpm i"
2123
},

packages/openapi-fetch/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@
5757
"prepublish": "pnpm run prepare && pnpm run build",
5858
"version": "pnpm run prepare && pnpm run build"
5959
},
60+
"dependencies": {
61+
"openapi-typescript-helpers": "^0.0.1"
62+
},
6063
"devDependencies": {
6164
"axios": "^1.4.0",
6265
"del-cli": "^5.0.0",

packages/openapi-fetch/src/index.ts

+25-54
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { ErrorResponse, HttpMethod, SuccessResponse, FilterKeys, MediaType, PathsWithMethod, ResponseObjectMap, OperationRequestBodyContent } from "openapi-typescript-helpers";
2+
13
// settings & const
24
const DEFAULT_HEADERS = {
35
"Content-Type": "application/json",
@@ -19,55 +21,24 @@ interface ClientOptions extends RequestInit {
1921
/** global bodySerializer */
2022
bodySerializer?: BodySerializer<unknown>;
2123
}
22-
export interface BaseParams {
23-
params?: { query?: Record<string, unknown> };
24-
}
25-
26-
// const
27-
28-
export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any };
24+
export type QuerySerializer<T> = (query: T extends { parameters: any } ? NonNullable<T["parameters"]["query"]> : Record<string, unknown>) => string;
25+
export type BodySerializer<T> = (body: OperationRequestBodyContent<T>) => any;
2926
export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream";
30-
export interface OperationObject {
31-
parameters: any;
32-
requestBody: any; // note: "any" will get overridden in inference
33-
responses: any;
27+
export interface DefaultParamsOption {
28+
params?: { query?: Record<string, unknown> };
3429
}
35-
export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace";
36-
export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX";
37-
// prettier-ignore
38-
export type ErrorStatus = 500 | '5XX' | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 429 | 431 | 444 | 450 | 451 | 497 | 498 | 499 | '4XX' | "default";
39-
40-
// util
41-
/** Get a union of paths which have method */
42-
export type PathsWith<Paths extends Record<string, PathItemObject>, PathnameMethod extends HttpMethod> = {
43-
[Pathname in keyof Paths]: Paths[Pathname] extends { [K in PathnameMethod]: any } ? Pathname : never;
44-
}[keyof Paths];
45-
/** Find first match of multiple keys */
46-
export type FilterKeys<Obj, Matchers> = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj];
47-
export type MediaType = `${string}/${string}`;
48-
49-
// general purpose types
50-
export type Params<T> = T extends { parameters: any } ? { params: NonNullable<T["parameters"]> } : BaseParams;
51-
export type RequestBodyObj<T> = T extends { requestBody?: any } ? T["requestBody"] : never;
52-
export type RequestBodyContent<T> = undefined extends RequestBodyObj<T> ? FilterKeys<NonNullable<RequestBodyObj<T>>, "content"> | undefined : FilterKeys<RequestBodyObj<T>, "content">;
53-
export type RequestBodyMedia<T> = FilterKeys<RequestBodyContent<T>, MediaType> extends never ? FilterKeys<NonNullable<RequestBodyContent<T>>, MediaType> | undefined : FilterKeys<RequestBodyContent<T>, MediaType>;
54-
export type RequestBody<T> = RequestBodyMedia<T> extends never ? { body?: never } : undefined extends RequestBodyMedia<T> ? { body?: RequestBodyMedia<T> } : { body: RequestBodyMedia<T> };
55-
export type QuerySerializer<T> = (query: T extends { parameters: any } ? NonNullable<T["parameters"]["query"]> : Record<string, unknown>) => string;
56-
export type BodySerializer<T> = (body: RequestBodyMedia<T>) => any;
57-
export type RequestOptions<T> = Params<T> &
58-
RequestBody<T> & {
30+
export type ParamsOption<T> = T extends { parameters: any } ? { params: NonNullable<T["parameters"]> } : DefaultParamsOption;
31+
export type RequestBodyOption<T> = OperationRequestBodyContent<T> extends never ? { body?: never } : undefined extends OperationRequestBodyContent<T> ? { body?: OperationRequestBodyContent<T> } : { body: OperationRequestBodyContent<T> };
32+
export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body">;
33+
export type FetchResponse<T> =
34+
| { data: FilterKeys<SuccessResponse<ResponseObjectMap<T>>, MediaType>; error?: never; response: Response }
35+
| { data?: never; error: FilterKeys<ErrorResponse<ResponseObjectMap<T>>, MediaType>; response: Response };
36+
export type RequestOptions<T> = ParamsOption<T> &
37+
RequestBodyOption<T> & {
5938
querySerializer?: QuerySerializer<T>;
6039
bodySerializer?: BodySerializer<T>;
6140
parseAs?: ParseAs;
6241
};
63-
export type Success<T> = FilterKeys<FilterKeys<T, OkStatus>, "content">;
64-
export type Error<T> = FilterKeys<FilterKeys<T, ErrorStatus>, "content">;
65-
66-
// fetch types
67-
export type FetchOptions<T> = RequestOptions<T> & Omit<RequestInit, "body">;
68-
export type FetchResponse<T> =
69-
| { data: T extends { responses: any } ? NonNullable<FilterKeys<Success<T["responses"]>, MediaType>> : unknown; error?: never; response: Response }
70-
| { data?: never; error: T extends { responses: any } ? NonNullable<FilterKeys<Error<T["responses"]>, MediaType>> : unknown; response: Response };
7142

7243
export default function createClient<Paths extends {}>(clientOptions: ClientOptions = {}) {
7344
const { fetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, ...options } = clientOptions;
@@ -94,7 +65,7 @@ export default function createClient<Paths extends {}>(clientOptions: ClientOpti
9465
// handle empty content
9566
// note: we return `{}` because we want user truthy checks for `.data` or `.error` to succeed
9667
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
97-
return response.ok ? { data: {} as any, response } : { error: {} as any, response };
68+
return response.ok ? { data: {} as any, response: response as any } : { error: {} as any, response: response as any };
9869
}
9970

10071
// parse response (falling back to .text() when necessary)
@@ -104,7 +75,7 @@ export default function createClient<Paths extends {}>(clientOptions: ClientOpti
10475
const cloned = response.clone();
10576
data = typeof cloned[parseAs] === "function" ? await cloned[parseAs]() : await cloned.text();
10677
}
107-
return { data, response };
78+
return { data, response: response as any };
10879
}
10980

11081
// handle errors (always parse as .json() or .text())
@@ -114,40 +85,40 @@ export default function createClient<Paths extends {}>(clientOptions: ClientOpti
11485
} catch {
11586
error = await response.clone().text();
11687
}
117-
return { error, response };
88+
return { error, response: response as any };
11889
}
11990

12091
return {
12192
/** Call a GET endpoint */
122-
async GET<P extends PathsWith<Paths, "get">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "get">>) {
93+
async GET<P extends PathsWithMethod<Paths, "get">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "get">>) {
12394
return coreFetch<P, "get">(url, { ...init, method: "GET" } as any);
12495
},
12596
/** Call a PUT endpoint */
126-
async PUT<P extends PathsWith<Paths, "put">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "put">>) {
97+
async PUT<P extends PathsWithMethod<Paths, "put">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "put">>) {
12798
return coreFetch<P, "put">(url, { ...init, method: "PUT" } as any);
12899
},
129100
/** Call a POST endpoint */
130-
async POST<P extends PathsWith<Paths, "post">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "post">>) {
101+
async POST<P extends PathsWithMethod<Paths, "post">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "post">>) {
131102
return coreFetch<P, "post">(url, { ...init, method: "POST" } as any);
132103
},
133104
/** Call a DELETE endpoint */
134-
async DELETE<P extends PathsWith<Paths, "delete">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "delete">>) {
105+
async DELETE<P extends PathsWithMethod<Paths, "delete">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "delete">>) {
135106
return coreFetch<P, "delete">(url, { ...init, method: "DELETE" } as any);
136107
},
137108
/** Call a OPTIONS endpoint */
138-
async OPTIONS<P extends PathsWith<Paths, "options">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "options">>) {
109+
async OPTIONS<P extends PathsWithMethod<Paths, "options">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "options">>) {
139110
return coreFetch<P, "options">(url, { ...init, method: "OPTIONS" } as any);
140111
},
141112
/** Call a HEAD endpoint */
142-
async HEAD<P extends PathsWith<Paths, "head">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "head">>) {
113+
async HEAD<P extends PathsWithMethod<Paths, "head">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "head">>) {
143114
return coreFetch<P, "head">(url, { ...init, method: "HEAD" } as any);
144115
},
145116
/** Call a PATCH endpoint */
146-
async PATCH<P extends PathsWith<Paths, "patch">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "patch">>) {
117+
async PATCH<P extends PathsWithMethod<Paths, "patch">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "patch">>) {
147118
return coreFetch<P, "patch">(url, { ...init, method: "PATCH" } as any);
148119
},
149120
/** Call a TRACE endpoint */
150-
async TRACE<P extends PathsWith<Paths, "trace">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "trace">>) {
121+
async TRACE<P extends PathsWithMethod<Paths, "trace">>(url: P, init: FetchOptions<FilterKeys<Paths[P], "trace">>) {
151122
return coreFetch<P, "trace">(url, { ...init, method: "TRACE" } as any);
152123
},
153124
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# openapi-typescript-helpers
2+
3+
## 0.0.0
4+
5+
Initial release
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Drew Powers
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# openapi-typescript-helpers
2+
3+
Helper utilities that power `openapi-fetch` but are generically-available for any project.
4+
5+
This package isn’t as well-documented as the others, so it’s a bit “use at your own discretion.”
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types */
2+
3+
// HTTP types
4+
5+
export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace";
6+
/** 2XX statuses */
7+
export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX";
8+
// prettier-ignore
9+
/** 4XX and 5XX statuses */
10+
export type ErrorStatus = 500 | '5XX' | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 429 | 431 | 444 | 450 | 451 | 497 | 498 | 499 | '4XX' | "default";
11+
12+
// OpenAPI type helpers
13+
14+
/** Given an OpenAPI **Paths Object**, find all paths that have the given method */
15+
export type PathsWithMethod<Paths extends Record<string, PathItemObject>, PathnameMethod extends HttpMethod> = {
16+
[Pathname in keyof Paths]: Paths[Pathname] extends { [K in PathnameMethod]: any } ? Pathname : never;
17+
}[keyof Paths];
18+
/** Internal helper used in PathsWithMethod */
19+
export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any };
20+
/** Return `responses` for an Operation Object */
21+
export type ResponseObjectMap<T> = T extends { responses: any } ? T["responses"] : unknown;
22+
/** Return `content` for a Response Object */
23+
export type ResponseContent<T> = T extends { content: any } ? T["content"] : unknown;
24+
/** Return `requestBody` for an Operation Object */
25+
export type OperationRequestBody<T> = T extends { requestBody?: any } ? T["requestBody"] : never;
26+
/** Internal helper used in OperationRequestBodyContent */
27+
export type OperationRequestBodyMediaContent<T> = undefined extends OperationRequestBody<T> ? FilterKeys<NonNullable<OperationRequestBody<T>>, "content"> | undefined : FilterKeys<OperationRequestBody<T>, "content">;
28+
/** Return first `content` from a Request Object Mapping, allowing any media type */
29+
export type OperationRequestBodyContent<T> = FilterKeys<OperationRequestBodyMediaContent<T>, MediaType> extends never
30+
? FilterKeys<NonNullable<OperationRequestBodyMediaContent<T>>, MediaType> | undefined
31+
: FilterKeys<OperationRequestBodyMediaContent<T>, MediaType>;
32+
/** Return first 2XX response from a Response Object Map */
33+
export type SuccessResponse<T> = FilterKeys<FilterKeys<T, OkStatus>, "content">;
34+
/** Return first 5XX or 4XX response (in that order) from a Response Object Map */
35+
export type ErrorResponse<T> = FilterKeys<FilterKeys<T, ErrorStatus>, "content">;
36+
37+
// Generic TS utils
38+
39+
/** Find first match of multiple keys */
40+
export type FilterKeys<Obj, Matchers> = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj];
41+
/** Return any `[string]/[string]` media type (important because openapi-fetch allows any content response, not just JSON-like) */
42+
export type MediaType = `${string}/${string}`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/**
2+
* Stub file to allow importing from the root of the package.
3+
*/
4+
export default null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "openapi-typescript-helpers",
3+
"description": "TypeScript helpers for consuming openapi-typescript types",
4+
"version": "0.0.1",
5+
"author": {
6+
"name": "Drew Powers",
7+
"email": "[email protected]"
8+
},
9+
"license": "MIT",
10+
"type": "module",
11+
"main": "./index.js",
12+
"types": "./index.d.ts",
13+
"exports": {
14+
".": {
15+
"default": "./index.js",
16+
"types": "./index.d.ts"
17+
},
18+
"./*": "./*"
19+
},
20+
"homepage": "https://openapi-ts.pages.dev",
21+
"repository": {
22+
"type": "git",
23+
"url": "https://github.com/drwpow/openapi-typescript",
24+
"directory": "packages/openapi-fetch"
25+
},
26+
"bugs": {
27+
"url": "https://github.com/drwpow/openapi-typescript/issues"
28+
},
29+
"scripts": {
30+
"lint": "pnpm run lint:js",
31+
"lint:js": "eslint \"*.{js,ts}\"",
32+
"lint:prettier": "prettier --check \"{src,test}/**/*\"",
33+
"test": "tsc --noEmit"
34+
},
35+
"devDependencies": {
36+
"typescript": "^5.1.6"
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"rootDir": "."
5+
},
6+
"include": ["."]
7+
}

packages/openapi-typescript/tsconfig.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
44
"sourceRoot": ".",
5-
"outDir": "dist"
5+
"outDir": "dist",
6+
"types": ["vitest/globals"]
67
},
78
"include": ["scripts", "src", "test", "*.ts"],
89
"exclude": ["examples", "node_modules"]

pnpm-lock.yaml

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tsconfig.json

-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"sourceMap": true,
1313
"strict": true,
1414
"target": "ESNext",
15-
"types": ["vitest/globals"],
1615
"useDefineForClassFields": true
1716
}
1817
}

0 commit comments

Comments
 (0)