diff --git a/.changeset/tiny-waves-help.md b/.changeset/tiny-waves-help.md new file mode 100644 index 000000000..6efb9f0a7 --- /dev/null +++ b/.changeset/tiny-waves-help.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": patch +--- + +Use openapi-typescript-helpers package for types diff --git a/package.json b/package.json index 8172419c0..c8c51041a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "build:openapi-fetch": "cd packages/openapi-fetch && pnpm run build", "lint": "run-p -s lint:*", "lint:openapi-typescript": "cd packages/openapi-typescript && pnpm run lint", + "lint:openapi-typescript-helpers": "cd packages/openapi-typescript-helpers && pnpm run lint", "lint:openapi-fetch": "cd packages/openapi-fetch && pnpm run lint", "test": "run-p -s test:*", "test:openapi-typescript": "cd packages/openapi-typescript && pnpm test", + "test:openapi-typescript-helpers": "cd packages/openapi-typescript-helpers && pnpm test", "test:openapi-fetch": "cd packages/openapi-fetch && pnpm test", "version": "pnpm run build && changeset version && pnpm i" }, diff --git a/packages/openapi-fetch/package.json b/packages/openapi-fetch/package.json index 4bee87b52..ae44f0c6c 100644 --- a/packages/openapi-fetch/package.json +++ b/packages/openapi-fetch/package.json @@ -57,6 +57,9 @@ "prepublish": "pnpm run prepare && pnpm run build", "version": "pnpm run prepare && pnpm run build" }, + "dependencies": { + "openapi-typescript-helpers": "^0.0.1" + }, "devDependencies": { "axios": "^1.4.0", "del-cli": "^5.0.0", diff --git a/packages/openapi-fetch/src/index.ts b/packages/openapi-fetch/src/index.ts index 263e435ca..4749f90b3 100644 --- a/packages/openapi-fetch/src/index.ts +++ b/packages/openapi-fetch/src/index.ts @@ -1,3 +1,5 @@ +import type { ErrorResponse, HttpMethod, SuccessResponse, FilterKeys, MediaType, PathsWithMethod, ResponseObjectMap, OperationRequestBodyContent } from "openapi-typescript-helpers"; + // settings & const const DEFAULT_HEADERS = { "Content-Type": "application/json", @@ -19,55 +21,24 @@ interface ClientOptions extends RequestInit { /** global bodySerializer */ bodySerializer?: BodySerializer; } -export interface BaseParams { - params?: { query?: Record }; -} - -// const - -export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any }; +export type QuerySerializer = (query: T extends { parameters: any } ? NonNullable : Record) => string; +export type BodySerializer = (body: OperationRequestBodyContent) => any; export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream"; -export interface OperationObject { - parameters: any; - requestBody: any; // note: "any" will get overridden in inference - responses: any; +export interface DefaultParamsOption { + params?: { query?: Record }; } -export type HttpMethod = "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace"; -export type OkStatus = 200 | 201 | 202 | 203 | 204 | 206 | 207 | "2XX"; -// prettier-ignore -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"; - -// util -/** Get a union of paths which have method */ -export type PathsWith, PathnameMethod extends HttpMethod> = { - [Pathname in keyof Paths]: Paths[Pathname] extends { [K in PathnameMethod]: any } ? Pathname : never; -}[keyof Paths]; -/** Find first match of multiple keys */ -export type FilterKeys = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj]; -export type MediaType = `${string}/${string}`; - -// general purpose types -export type Params = T extends { parameters: any } ? { params: NonNullable } : BaseParams; -export type RequestBodyObj = T extends { requestBody?: any } ? T["requestBody"] : never; -export type RequestBodyContent = undefined extends RequestBodyObj ? FilterKeys>, "content"> | undefined : FilterKeys, "content">; -export type RequestBodyMedia = FilterKeys, MediaType> extends never ? FilterKeys>, MediaType> | undefined : FilterKeys, MediaType>; -export type RequestBody = RequestBodyMedia extends never ? { body?: never } : undefined extends RequestBodyMedia ? { body?: RequestBodyMedia } : { body: RequestBodyMedia }; -export type QuerySerializer = (query: T extends { parameters: any } ? NonNullable : Record) => string; -export type BodySerializer = (body: RequestBodyMedia) => any; -export type RequestOptions = Params & - RequestBody & { +export type ParamsOption = T extends { parameters: any } ? { params: NonNullable } : DefaultParamsOption; +export type RequestBodyOption = OperationRequestBodyContent extends never ? { body?: never } : undefined extends OperationRequestBodyContent ? { body?: OperationRequestBodyContent } : { body: OperationRequestBodyContent }; +export type FetchOptions = RequestOptions & Omit; +export type FetchResponse = + | { data: FilterKeys>, MediaType>; error?: never; response: Response } + | { data?: never; error: FilterKeys>, MediaType>; response: Response }; +export type RequestOptions = ParamsOption & + RequestBodyOption & { querySerializer?: QuerySerializer; bodySerializer?: BodySerializer; parseAs?: ParseAs; }; -export type Success = FilterKeys, "content">; -export type Error = FilterKeys, "content">; - -// fetch types -export type FetchOptions = RequestOptions & Omit; -export type FetchResponse = - | { data: T extends { responses: any } ? NonNullable, MediaType>> : unknown; error?: never; response: Response } - | { data?: never; error: T extends { responses: any } ? NonNullable, MediaType>> : unknown; response: Response }; export default function createClient(clientOptions: ClientOptions = {}) { const { fetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, ...options } = clientOptions; @@ -94,7 +65,7 @@ export default function createClient(clientOptions: ClientOpti // handle empty content // note: we return `{}` because we want user truthy checks for `.data` or `.error` to succeed if (response.status === 204 || response.headers.get("Content-Length") === "0") { - return response.ok ? { data: {} as any, response } : { error: {} as any, response }; + return response.ok ? { data: {} as any, response: response as any } : { error: {} as any, response: response as any }; } // parse response (falling back to .text() when necessary) @@ -104,7 +75,7 @@ export default function createClient(clientOptions: ClientOpti const cloned = response.clone(); data = typeof cloned[parseAs] === "function" ? await cloned[parseAs]() : await cloned.text(); } - return { data, response }; + return { data, response: response as any }; } // handle errors (always parse as .json() or .text()) @@ -114,40 +85,40 @@ export default function createClient(clientOptions: ClientOpti } catch { error = await response.clone().text(); } - return { error, response }; + return { error, response: response as any }; } return { /** Call a GET endpoint */ - async GET

>(url: P, init: FetchOptions>) { + async GET

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "GET" } as any); }, /** Call a PUT endpoint */ - async PUT

>(url: P, init: FetchOptions>) { + async PUT

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "PUT" } as any); }, /** Call a POST endpoint */ - async POST

>(url: P, init: FetchOptions>) { + async POST

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "POST" } as any); }, /** Call a DELETE endpoint */ - async DELETE

>(url: P, init: FetchOptions>) { + async DELETE

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "DELETE" } as any); }, /** Call a OPTIONS endpoint */ - async OPTIONS

>(url: P, init: FetchOptions>) { + async OPTIONS

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "OPTIONS" } as any); }, /** Call a HEAD endpoint */ - async HEAD

>(url: P, init: FetchOptions>) { + async HEAD

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "HEAD" } as any); }, /** Call a PATCH endpoint */ - async PATCH

>(url: P, init: FetchOptions>) { + async PATCH

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "PATCH" } as any); }, /** Call a TRACE endpoint */ - async TRACE

>(url: P, init: FetchOptions>) { + async TRACE

>(url: P, init: FetchOptions>) { return coreFetch(url, { ...init, method: "TRACE" } as any); }, }; diff --git a/packages/openapi-typescript-helpers/CHANGELOG.md b/packages/openapi-typescript-helpers/CHANGELOG.md new file mode 100644 index 000000000..30a964ead --- /dev/null +++ b/packages/openapi-typescript-helpers/CHANGELOG.md @@ -0,0 +1,5 @@ +# openapi-typescript-helpers + +## 0.0.0 + +Initial release diff --git a/packages/openapi-typescript-helpers/LICENSE b/packages/openapi-typescript-helpers/LICENSE new file mode 100644 index 000000000..5de76d308 --- /dev/null +++ b/packages/openapi-typescript-helpers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Drew Powers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/openapi-typescript-helpers/README.md b/packages/openapi-typescript-helpers/README.md new file mode 100644 index 000000000..0fbf9c367 --- /dev/null +++ b/packages/openapi-typescript-helpers/README.md @@ -0,0 +1,5 @@ +# openapi-typescript-helpers + +Helper utilities that power `openapi-fetch` but are generically-available for any project. + +This package isn’t as well-documented as the others, so it’s a bit “use at your own discretion.” diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts new file mode 100644 index 000000000..357a95101 --- /dev/null +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types */ + +// HTTP types + +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 +/** 4XX and 5XX statuses */ +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"; + +// OpenAPI type helpers + +/** Given an OpenAPI **Paths Object**, find all paths that have the given method */ +export type PathsWithMethod, PathnameMethod extends HttpMethod> = { + [Pathname in keyof Paths]: Paths[Pathname] extends { [K in PathnameMethod]: any } ? Pathname : never; +}[keyof Paths]; +/** Internal helper used in PathsWithMethod */ +export type PathItemObject = { [M in HttpMethod]: OperationObject } & { parameters?: any }; +/** Return `responses` for an Operation Object */ +export type ResponseObjectMap = T extends { responses: any } ? T["responses"] : unknown; +/** Return `content` for a Response Object */ +export type ResponseContent = T extends { content: any } ? T["content"] : unknown; +/** Return `requestBody` for an Operation Object */ +export type OperationRequestBody = T extends { requestBody?: any } ? T["requestBody"] : never; +/** Internal helper used in OperationRequestBodyContent */ +export type OperationRequestBodyMediaContent = undefined extends OperationRequestBody ? FilterKeys>, "content"> | undefined : FilterKeys, "content">; +/** Return first `content` from a Request Object Mapping, allowing any media type */ +export type OperationRequestBodyContent = FilterKeys, MediaType> extends never + ? FilterKeys>, MediaType> | undefined + : FilterKeys, MediaType>; +/** Return first 2XX response from a Response Object Map */ +export type SuccessResponse = FilterKeys, "content">; +/** Return first 5XX or 4XX response (in that order) from a Response Object Map */ +export type ErrorResponse = FilterKeys, "content">; + +// Generic TS utils + +/** Find first match of multiple keys */ +export type FilterKeys = { [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}`; diff --git a/packages/openapi-typescript-helpers/index.js b/packages/openapi-typescript-helpers/index.js new file mode 100644 index 000000000..6e5f33030 --- /dev/null +++ b/packages/openapi-typescript-helpers/index.js @@ -0,0 +1,4 @@ +/** + * Stub file to allow importing from the root of the package. + */ +export default null; diff --git a/packages/openapi-typescript-helpers/package.json b/packages/openapi-typescript-helpers/package.json new file mode 100644 index 000000000..c7241d4e9 --- /dev/null +++ b/packages/openapi-typescript-helpers/package.json @@ -0,0 +1,38 @@ +{ + "name": "openapi-typescript-helpers", + "description": "TypeScript helpers for consuming openapi-typescript types", + "version": "0.0.1", + "author": { + "name": "Drew Powers", + "email": "drew@pow.rs" + }, + "license": "MIT", + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "default": "./index.js", + "types": "./index.d.ts" + }, + "./*": "./*" + }, + "homepage": "https://openapi-ts.pages.dev", + "repository": { + "type": "git", + "url": "https://github.com/drwpow/openapi-typescript", + "directory": "packages/openapi-fetch" + }, + "bugs": { + "url": "https://github.com/drwpow/openapi-typescript/issues" + }, + "scripts": { + "lint": "pnpm run lint:js", + "lint:js": "eslint \"*.{js,ts}\"", + "lint:prettier": "prettier --check \"{src,test}/**/*\"", + "test": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.1.6" + } +} diff --git a/packages/openapi-typescript-helpers/tsconfig.json b/packages/openapi-typescript-helpers/tsconfig.json new file mode 100644 index 000000000..762bd96d6 --- /dev/null +++ b/packages/openapi-typescript-helpers/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["."] +} diff --git a/packages/openapi-typescript/tsconfig.json b/packages/openapi-typescript/tsconfig.json index a5d4a6abb..bd4969bc2 100644 --- a/packages/openapi-typescript/tsconfig.json +++ b/packages/openapi-typescript/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "sourceRoot": ".", - "outDir": "dist" + "outDir": "dist", + "types": ["vitest/globals"] }, "include": ["scripts", "src", "test", "*.ts"], "exclude": ["examples", "node_modules"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86392e437..cc2d7fc37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,10 @@ importers: version: 5.1.6 packages/openapi-fetch: + dependencies: + openapi-typescript-helpers: + specifier: ^0.0.1 + version: link:../openapi-typescript-helpers devDependencies: axios: specifier: ^1.4.0 @@ -188,6 +192,12 @@ importers: specifier: ^0.34.1 version: 0.34.1(supports-color@9.4.0) + packages/openapi-typescript-helpers: + devDependencies: + typescript: + specifier: ^5.1.6 + version: 5.1.6 + packages: /@aashutoshrathi/word-wrap@1.2.6: diff --git a/tsconfig.json b/tsconfig.json index 204c13843..93bb9800a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,6 @@ "sourceMap": true, "strict": true, "target": "ESNext", - "types": ["vitest/globals"], "useDefineForClassFields": true } }