diff --git a/README.md b/README.md index ff610bf4..5985f871 100644 --- a/README.md +++ b/README.md @@ -1,388 +1,402 @@ -# ofetch - - - -[![npm version](https://img.shields.io/npm/v/ofetch)](https://npmjs.com/package/ofetch) -[![npm downloads](https://img.shields.io/npm/dm/ofetch)](https://npm.chart.dev/ofetch) - - - -A better fetch API. Works on node, browser, and workers. - -> [!IMPORTANT] -> You are on v2 (alpha) development branch. See [v1](https://github.com/unjs/ofetch/tree/v1) for v1 docs. - -
- Spoiler - -
- -## 🚀 Quick Start - -Install: - -```bash -npx nypm i ofetch -``` - -Import: - -```js -import { ofetch } from "ofetch"; -``` - -## ✔️ Parsing Response - -`ofetch` smartly parse JSON responses. - -```js -const { users } = await ofetch("/api/users"); -``` - -For binary content types, `ofetch` will instead return a `Blob` object. - -You can optionally provide a different parser than `JSON.parse`, or specify `blob`, `arrayBuffer`, `text` or `stream` to force parsing the body with the respective `FetchResponse` method. - -```js -// Return text as is -await ofetch("/movie?lang=en", { parseResponse: (txt) => txt }); - -// Get the blob version of the response -await ofetch("/api/generate-image", { responseType: "blob" }); - -// Get the stream version of the response -await ofetch("/api/generate-image", { responseType: "stream" }); -``` - -## ✔️ JSON Body - -If an object or a class with a `.toJSON()` method is passed to the `body` option, `ofetch` automatically stringifies it. - -`ofetch` utilizes `JSON.stringify()` to convert the passed object. Classes without a `.toJSON()` method have to be converted into a string value in advance before being passed to the `body` option. - -For `PUT`, `PATCH`, and `POST` request methods, when a string or object body is set, `ofetch` adds the default `"content-type": "application/json"` and `accept: "application/json"` headers (which you can always override). - -Additionally, `ofetch` supports binary responses with `Buffer`, `ReadableStream`, `Stream`, and [compatible body types](https://developer.mozilla.org/en-US/docs/Web/API/fetch#body). `ofetch` will automatically set the `duplex: "half"` option for streaming support! - -**Example:** - -```js -const { users } = await ofetch("/api/users", { - method: "POST", - body: { some: "json" }, -}); -``` - -## ✔️ Handling Errors - -`ofetch` Automatically throws errors when `response.ok` is `false` with a friendly error message and compact stack (hiding internals). - -A parsed error body is available with `error.data`. You may also use `FetchError` type. - -```ts -await ofetch("https://google.com/404"); -// FetchError: [GET] "https://google/404": 404 Not Found -// at async main (/project/playground.ts:4:3) -``` - -To catch error response: - -```ts -await ofetch("/url").catch((error) => error.data); -``` - -To bypass status error catching you can set `ignoreResponseError` option: - -```ts -await ofetch("/url", { ignoreResponseError: true }); -``` - -## ✔️ Auto Retry - -`ofetch` Automatically retries the request if an error happens and if the response status code is included in `retryStatusCodes` list: - -**Retry status codes:** - -- `408` - Request Timeout -- `409` - Conflict -- `425` - Too Early ([Experimental](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Early-Data)) -- `429` - Too Many Requests -- `500` - Internal Server Error -- `502` - Bad Gateway -- `503` - Service Unavailable -- `504` - Gateway Timeout - -You can specify the amount of retry and delay between them using `retry` and `retryDelay` options and also pass a custom array of codes using `retryStatusCodes` option. - -The default for `retry` is `1` retry, except for `POST`, `PUT`, `PATCH`, and `DELETE` methods where `ofetch` does not retry by default to avoid introducing side effects. If you set a custom value for `retry` it will **always retry** for all requests. - -The default for `retryDelay` is `0` ms. - -```ts -await ofetch("http://google.com/404", { - retry: 3, - retryDelay: 500, // ms - retryStatusCodes: [404, 500], // response status codes to retry -}); -``` - -## ✔️ Timeout - -You can specify `timeout` in milliseconds to automatically abort a request after a timeout (default is disabled). - -```ts -await ofetch("http://google.com/404", { - timeout: 3000, // Timeout after 3 seconds -}); -``` - -## ✔️ Type Friendly - -The response can be type assisted: - -```ts -const article = await ofetch
(`/api/article/${id}`); -// Auto complete working with article.id -``` - -## ✔️ Adding `baseURL` - -By using `baseURL` option, `ofetch` prepends it for trailing/leading slashes and query search params for baseURL using [ufo](https://github.com/unjs/ufo): - -```js -await ofetch("/config", { baseURL }); -``` - -## ✔️ Adding Query Search Params - -By using `query` option (or `params` as alias), `ofetch` adds query search params to the URL by preserving the query in the request itself using [ufo](https://github.com/unjs/ufo): - -```js -await ofetch("/movie?lang=en", { query: { id: 123 } }); -``` - -## ✔️ Interceptors - -Providing async interceptors to hook into lifecycle events of `ofetch` call is possible. - -You might want to use `ofetch.create` to set shared interceptors. - -### `onRequest({ request, options })` - -`onRequest` is called as soon as `ofetch` is called, allowing you to modify options or do simple logging. - -```js -await ofetch("/api", { - async onRequest({ request, options }) { - // Log request - console.log("[fetch request]", request, options); - - // Add `?t=1640125211170` to query search params - options.query = options.query || {}; - options.query.t = new Date(); - }, -}); -``` - -### `onRequestError({ request, options, error })` - -`onRequestError` will be called when the fetch request fails. - -```js -await ofetch("/api", { - async onRequestError({ request, options, error }) { - // Log error - console.log("[fetch request error]", request, error); - }, -}); -``` - -### `onResponse({ request, options, response })` - -`onResponse` will be called after `fetch` call and parsing body. - -```js -await ofetch("/api", { - async onResponse({ request, response, options }) { - // Log response - console.log("[fetch response]", request, response.status, response.body); - }, -}); -``` - -### `onResponseError({ request, options, response })` - -`onResponseError` is the same as `onResponse` but will be called when fetch happens but `response.ok` is not `true`. - -```js -await ofetch("/api", { - async onResponseError({ request, response, options }) { - // Log error - console.log( - "[fetch response error]", - request, - response.status, - response.body - ); - }, -}); -``` - -### Passing array of interceptors - -If necessary, it's also possible to pass an array of function that will be called sequentially. - -```js -await ofetch("/api", { - onRequest: [ - () => { - /* Do something */ - }, - () => { - /* Do something else */ - }, - ], -}); -``` - -## ✔️ Create fetch with default options - -This utility is useful if you need to use common options across several fetch calls. - -**Note:** Defaults will be cloned at one level and inherited. Be careful about nested options like `headers`. - -```js -const apiFetch = ofetch.create({ baseURL: "/api" }); - -apiFetch("/test"); // Same as ofetch('/test', { baseURL: '/api' }) -``` - -## 💡 Adding headers - -By using `headers` option, `ofetch` adds extra headers in addition to the request default headers: - -```js -await ofetch("/movies", { - headers: { - Accept: "application/json", - "Cache-Control": "no-cache", - }, -}); -``` - -## 🍣 Access to Raw Response - -If you need to access raw response (for headers, etc), you can use `ofetch.raw`: - -```js -const response = await ofetch.raw("/sushi"); - -// response._data -// response.headers -// ... -``` - -## 🌿 Using Native Fetch - -As a shortcut, you can use `ofetch.native` that provides native `fetch` API - -```js -const json = await ofetch.native("/sushi").then((r) => r.json()); -``` - -## 📡 SSE - -**Example:** Handle SSE response: - -```js -const stream = await ofetch("/sse"); -const reader = stream.getReader(); -const decoder = new TextDecoder(); -while (true) { - const { done, value } = await reader.read(); - if (done) break; - // Here is the chunked text of the SSE response. - const text = decoder.decode(value); -} -``` - -## 🕵️ Proxy Support - -> [!IMPORTANT] -> **Environment Variables:** Bun and Deno respect `HTTP_PROXY` and `HTTPS_PROXY` environment variables. Node.js requires setting `NODE_USE_ENV_PROXY=1` to enable [built-in proxy support](https://nodejs.org/api/http.html#http_built_in_proxy_support). - -### Node.js - -In Node.js (>= 18), you can use the `dispatcher` option with [undici](https://undici.nodejs.org/)'s `ProxyAgent`. - -```ts -import { ProxyAgent } from "undici"; - -const proxyAgent = new ProxyAgent("http://localhost:3128"); - -await ofetch("https://icanhazip.com", { dispatcher: proxyAgent }); -``` - -**Example:** Set proxy globally for all requests: - -```ts -import { ProxyAgent, setGlobalDispatcher } from "undici"; - -setGlobalDispatcher(new ProxyAgent("http://localhost:3128")); -``` - -**Example:** Allow self-signed certificates (USE AT YOUR OWN RISK!) - -```ts -import { Agent } from "undici"; - -// Note: This makes fetch unsecure against MITM attacks. USE AT YOUR OWN RISK! -const unsecureAgent = new Agent({ connect: { rejectUnauthorized: false } }); -await ofetch("https://self-signed.example.com/", { dispatcher: unsecureAgent }); -``` - -### Bun and Deno - -**Bun** supports the `proxy` option: - -```ts -await ofetch("https://icanhazip.com", { - proxy: "http://localhost:3128", -}); -``` - -**Deno** can also use undici with npm specifiers for programmatic configuration. - -### 💪 Augment `FetchOptions` interface - -You can augment the `FetchOptions` interface to add custom properties. - -```ts -// Place this in any `.ts` or `.d.ts` file. -// Ensure it's included in the project's tsconfig.json "files". -declare module "ofetch" { - interface FetchOptions { - // Custom properties - requiresAuth?: boolean; - } -} - -export {}; -``` - -This lets you pass and use those properties with full type safety throughout `ofetch` calls. - -```ts -const myFetch = ofetch.create({ - onRequest(context) { - // ^? { ..., options: {..., requiresAuth?: boolean }} - console.log(context.options.requiresAuth); - }, -}); - -myFetch("/foo", { requiresAuth: true }); -``` - -## License - -💛 Published under the [MIT](https://github.com/h3js/rou3/blob/main/LICENSE) license. +# ofetch + + + +[![npm version](https://img.shields.io/npm/v/ofetch)](https://npmjs.com/package/ofetch) +[![npm downloads](https://img.shields.io/npm/dm/ofetch)](https://npm.chart.dev/ofetch) + + + +A better fetch API. Works on node, browser, and workers. + +> [!IMPORTANT] +> You are on v2 (alpha) development branch. See [v1](https://github.com/unjs/ofetch/tree/v1) for v1 docs. + +
+ Spoiler + +
+ +## 🚀 Quick Start + +Install: + +```bash +npx nypm i ofetch +``` + +Import: + +```js +import { ofetch } from "ofetch"; +``` + +## ✔️ Parsing Response + +`ofetch` smartly parse JSON responses. + +```js +const { users } = await ofetch("/api/users"); +``` + +For binary content types, `ofetch` will instead return a `Blob` object. + +You can optionally provide a different parser than `JSON.parse`, or specify `blob`, `arrayBuffer`, `text` or `stream` to force parsing the body with the respective `FetchResponse` method. + +```js +// Return text as is +await ofetch("/movie?lang=en", { parseResponse: (txt) => txt }); + +// Get the blob version of the response +await ofetch("/api/generate-image", { responseType: "blob" }); + +// Get the stream version of the response +await ofetch("/api/generate-image", { responseType: "stream" }); +``` + +## ✔️ JSON Body + +If an object or a class with a `.toJSON()` method is passed to the `body` option, `ofetch` automatically stringifies it. + +`ofetch` utilizes `JSON.stringify()` to convert the passed object. Classes without a `.toJSON()` method have to be converted into a string value in advance before being passed to the `body` option. + +For `PUT`, `PATCH`, and `POST` request methods, when a string or object body is set, `ofetch` adds the default `"content-type": "application/json"` and `accept: "application/json"` headers (which you can always override). + +Additionally, `ofetch` supports binary responses with `Buffer`, `ReadableStream`, `Stream`, and [compatible body types](https://developer.mozilla.org/en-US/docs/Web/API/fetch#body). `ofetch` will automatically set the `duplex: "half"` option for streaming support! + +**Example:** + +```js +const { users } = await ofetch("/api/users", { + method: "POST", + body: { some: "json" }, +}); +``` + +## ✔️ Handling Errors + +`ofetch` Automatically throws errors when `response.ok` is `false` with a friendly error message and compact stack (hiding internals). + +A parsed error body is available with `error.data`. You may also use `FetchError` type. + +```ts +await ofetch("https://google.com/404"); +// FetchError: [GET] "https://google/404": 404 Not Found +// at async main (/project/playground.ts:4:3) +``` + +To catch error response: + +```ts +await ofetch("/url").catch((error) => error.data); +``` + +To bypass status error catching you can set `ignoreResponseError` option: + +```ts +await ofetch("/url", { ignoreResponseError: true }); +``` + +## ✔️ Auto Retry + +`ofetch` Automatically retries the request if an error happens and if the response status code is included in `retryStatusCodes` list: + +**Retry status codes:** + +- `408` - Request Timeout +- `409` - Conflict +- `425` - Too Early ([Experimental](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Early-Data)) +- `429` - Too Many Requests +- `500` - Internal Server Error +- `502` - Bad Gateway +- `503` - Service Unavailable +- `504` - Gateway Timeout + +You can specify the amount of retry and delay between them using `retry` and `retryDelay` options and also pass a custom array of codes using `retryStatusCodes` option. + +The default for `retry` is `1` retry, except for `POST`, `PUT`, `PATCH`, and `DELETE` methods where `ofetch` does not retry by default to avoid introducing side effects. If you set a custom value for `retry` it will **always retry** for all requests. + +The default for `retryDelay` is `0` ms. + +```ts +await ofetch("http://google.com/404", { + retry: 3, + retryDelay: 500, // ms + retryStatusCodes: [404, 500], // response status codes to retry +}); +``` + +## ✔️ Timeout + +You can specify `timeout` in milliseconds to automatically abort a request after a timeout (default is disabled). + +```ts +await ofetch("http://google.com/404", { + timeout: 3000, // Timeout after 3 seconds +}); +``` + +## ✔️ Type Friendly + +The response can be type assisted: + +```ts +const article = await ofetch
(`/api/article/${id}`); +// Auto complete working with article.id +``` + +You can also type an instance created with `ofetch.create` using an +OpenAPI-like `paths` schema (for example generated by `openapi-typescript`): + +```ts +import type { paths } from "./autogenerated-types/my-api"; + +const apiFetch = ofetch.create({ baseURL: "/api" }); + +const user = await apiFetch("/users/{id}", { + method: "GET", + query: { expand: true }, +}); +``` + +## ✔️ Adding `baseURL` + +By using `baseURL` option, `ofetch` prepends it for trailing/leading slashes and query search params for baseURL using [ufo](https://github.com/unjs/ufo): + +```js +await ofetch("/config", { baseURL }); +``` + +## ✔️ Adding Query Search Params + +By using `query` option (or `params` as alias), `ofetch` adds query search params to the URL by preserving the query in the request itself using [ufo](https://github.com/unjs/ufo): + +```js +await ofetch("/movie?lang=en", { query: { id: 123 } }); +``` + +## ✔️ Interceptors + +Providing async interceptors to hook into lifecycle events of `ofetch` call is possible. + +You might want to use `ofetch.create` to set shared interceptors. + +### `onRequest({ request, options })` + +`onRequest` is called as soon as `ofetch` is called, allowing you to modify options or do simple logging. + +```js +await ofetch("/api", { + async onRequest({ request, options }) { + // Log request + console.log("[fetch request]", request, options); + + // Add `?t=1640125211170` to query search params + options.query = options.query || {}; + options.query.t = new Date(); + }, +}); +``` + +### `onRequestError({ request, options, error })` + +`onRequestError` will be called when the fetch request fails. + +```js +await ofetch("/api", { + async onRequestError({ request, options, error }) { + // Log error + console.log("[fetch request error]", request, error); + }, +}); +``` + +### `onResponse({ request, options, response })` + +`onResponse` will be called after `fetch` call and parsing body. + +```js +await ofetch("/api", { + async onResponse({ request, response, options }) { + // Log response + console.log("[fetch response]", request, response.status, response.body); + }, +}); +``` + +### `onResponseError({ request, options, response })` + +`onResponseError` is the same as `onResponse` but will be called when fetch happens but `response.ok` is not `true`. + +```js +await ofetch("/api", { + async onResponseError({ request, response, options }) { + // Log error + console.log( + "[fetch response error]", + request, + response.status, + response.body + ); + }, +}); +``` + +### Passing array of interceptors + +If necessary, it's also possible to pass an array of function that will be called sequentially. + +```js +await ofetch("/api", { + onRequest: [ + () => { + /* Do something */ + }, + () => { + /* Do something else */ + }, + ], +}); +``` + +## ✔️ Create fetch with default options + +This utility is useful if you need to use common options across several fetch calls. + +**Note:** Defaults will be cloned at one level and inherited. Be careful about nested options like `headers`. + +```js +const apiFetch = ofetch.create({ baseURL: "/api" }); + +apiFetch("/test"); // Same as ofetch('/test', { baseURL: '/api' }) +``` + +## 💡 Adding headers + +By using `headers` option, `ofetch` adds extra headers in addition to the request default headers: + +```js +await ofetch("/movies", { + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + }, +}); +``` + +## 🍣 Access to Raw Response + +If you need to access raw response (for headers, etc), you can use `ofetch.raw`: + +```js +const response = await ofetch.raw("/sushi"); + +// response._data +// response.headers +// ... +``` + +## 🌿 Using Native Fetch + +As a shortcut, you can use `ofetch.native` that provides native `fetch` API + +```js +const json = await ofetch.native("/sushi").then((r) => r.json()); +``` + +## 📡 SSE + +**Example:** Handle SSE response: + +```js +const stream = await ofetch("/sse"); +const reader = stream.getReader(); +const decoder = new TextDecoder(); +while (true) { + const { done, value } = await reader.read(); + if (done) break; + // Here is the chunked text of the SSE response. + const text = decoder.decode(value); +} +``` + +## 🕵️ Proxy Support + +> [!IMPORTANT] +> **Environment Variables:** Bun and Deno respect `HTTP_PROXY` and `HTTPS_PROXY` environment variables. Node.js requires setting `NODE_USE_ENV_PROXY=1` to enable [built-in proxy support](https://nodejs.org/api/http.html#http_built_in_proxy_support). + +### Node.js + +In Node.js (>= 18), you can use the `dispatcher` option with [undici](https://undici.nodejs.org/)'s `ProxyAgent`. + +```ts +import { ProxyAgent } from "undici"; + +const proxyAgent = new ProxyAgent("http://localhost:3128"); + +await ofetch("https://icanhazip.com", { dispatcher: proxyAgent }); +``` + +**Example:** Set proxy globally for all requests: + +```ts +import { ProxyAgent, setGlobalDispatcher } from "undici"; + +setGlobalDispatcher(new ProxyAgent("http://localhost:3128")); +``` + +**Example:** Allow self-signed certificates (USE AT YOUR OWN RISK!) + +```ts +import { Agent } from "undici"; + +// Note: This makes fetch unsecure against MITM attacks. USE AT YOUR OWN RISK! +const unsecureAgent = new Agent({ connect: { rejectUnauthorized: false } }); +await ofetch("https://self-signed.example.com/", { dispatcher: unsecureAgent }); +``` + +### Bun and Deno + +**Bun** supports the `proxy` option: + +```ts +await ofetch("https://icanhazip.com", { + proxy: "http://localhost:3128", +}); +``` + +**Deno** can also use undici with npm specifiers for programmatic configuration. + +### 💪 Augment `FetchOptions` interface + +You can augment the `FetchOptions` interface to add custom properties. + +```ts +// Place this in any `.ts` or `.d.ts` file. +// Ensure it's included in the project's tsconfig.json "files". +declare module "ofetch" { + interface FetchOptions { + // Custom properties + requiresAuth?: boolean; + } +} + +export {}; +``` + +This lets you pass and use those properties with full type safety throughout `ofetch` calls. + +```ts +const myFetch = ofetch.create({ + onRequest(context) { + // ^? { ..., options: {..., requiresAuth?: boolean }} + console.log(context.options.requiresAuth); + }, +}); + +myFetch("/foo", { requiresAuth: true }); +``` + +## License + +💛 Published under the [MIT](https://github.com/h3js/rou3/blob/main/LICENSE) license. diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..d22f1748 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -33,7 +33,9 @@ const retryStatusCodes = new Set([ // https://developer.mozilla.org/en-US/docs/Web/API/Response/body const nullBodyResponses = new Set([101, 204, 205, 304]); -export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { +export function createFetch( + globalOptions: CreateFetchOptions = {} +): $Fetch { const { fetch = globalThis.fetch } = globalOptions; async function onError(context: FetchContext): Promise> { @@ -256,10 +258,13 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { return context.response; }; - const $fetch = async function $fetch(request, options) { + const $fetch = async function $fetch( + request: FetchRequest, + options?: FetchOptions + ) { const r = await $fetchRaw(request, options); return r._data; - } as $Fetch; + } as $Fetch; $fetch.raw = $fetchRaw; diff --git a/src/types.ts b/src/types.ts index 66a84faf..29be654c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,9 +2,170 @@ // $fetch API // -------------------------- -export interface $Fetch { - ( - request: FetchRequest, +type HttpMethod = + | "get" + | "post" + | "put" + | "patch" + | "delete" + | "head" + | "options" + | "trace"; + +type _UntypedSchemaMarker = void; + +type PathsForSchema = Extract; + +type PathsWithMethod = { + [TPath in PathsForSchema]: TMethod extends MethodsForPath< + TSchema, + TPath + > + ? TPath + : never; +}[PathsForSchema]; + +type GetMethodForPath> = Extract< + "get", + MethodsForPath +>; + +type MethodInputForPath> = + | MethodsForPath + | Uppercase>; + +type MethodFromInput< + TSchema, + TPath extends PathsForSchema, + TInput extends MethodInputForPath, +> = Extract, MethodsForPath>; + +type MethodsForPath> = Extract< + Extract, + HttpMethod +>; + +type OperationFor< + TSchema, + TPath extends PathsForSchema, + TMethod extends MethodsForPath, +> = TSchema[TPath][TMethod]; + +type UntypedRequest = [ + TSchema, +] extends [_UntypedSchemaMarker] + ? TRequest + : TRequest extends string + ? never + : TRequest; + +type OperationRequestBody = T extends { requestBody: infer TRequestBody } + ? TRequestBody extends { content: infer TContent } + ? TContent[keyof TContent] + : never + : never; + +type OperationHasRequiredBody = T extends { + requestBody: { required: true }; +} + ? true + : false; + +type OperationResponses = T extends { responses: infer TResponses } + ? TResponses + : never; + +type SuccessStatusCode = `${2}${number}${number}` | "2XX" | "default"; + +type SuccessResponses = + TResponses extends Record + ? { + [TStatus in keyof TResponses]: `${TStatus & (string | number)}` extends SuccessStatusCode + ? TResponses[TStatus] + : never; + }[keyof TResponses] + : never; + +type ResponseBodyFromResponse = T extends { content: infer TContent } + ? TContent[keyof TContent] + : never; + +type OperationData = ResponseBodyFromResponse< + SuccessResponses> +>; + +type OperationQuery = T extends { parameters: infer TParameters } + ? TParameters extends { query: infer TQuery } + ? TQuery + : never + : never; + +type QueryOption = [OperationQuery] extends [never] + ? {} + : { + query?: OperationQuery; + /** + * @deprecated use query instead. + */ + params?: OperationQuery; + }; + +type BodyOption = [OperationRequestBody] extends [never] + ? {} + : OperationHasRequiredBody extends true + ? { body: OperationRequestBody } + : { body?: OperationRequestBody }; + +type TypedFetchOptions = Omit< + FetchOptions>, + "query" | "params" | "body" +> & + QueryOption & + BodyOption; + +export interface $Fetch { + < + TPath extends PathsForSchema, + TMethodInput extends MethodInputForPath, + TMethod extends MethodsForPath = MethodFromInput< + TSchema, + TPath, + TMethodInput + >, + R extends ResponseType = "json", + >( + request: TPath, + options: TypedFetchOptions, R> & { + method: TMethodInput; + } + ): Promise< + MappedResponseType>> + >; + < + TPath extends PathsWithMethod, + R extends ResponseType = "json", + >( + request: TPath, + options?: TypedFetchOptions< + OperationFor>, + R + > & { + method?: "get" | "GET"; + } + ): Promise< + MappedResponseType< + R, + OperationData< + OperationFor> + > + > + >; + < + T = any, + R extends ResponseType = "json", + TRequest extends FetchRequest = FetchRequest, + >( + request: UntypedRequest, options?: FetchOptions ): Promise>; raw( @@ -12,7 +173,10 @@ export interface $Fetch { options?: FetchOptions ): Promise>>; native: Fetch; - create(defaults: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch; + create( + defaults: FetchOptions, + globalOptions?: CreateFetchOptions + ): $Fetch; } // -------------------------- diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 00000000..6bcdcfa6 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,109 @@ +import { describe, it } from "vitest"; +import { createFetch } from "../src/index.ts"; + +type ApiPaths = { + "/users": { + get: { + responses: { + 200: { content: { "application/json": { users: string[] } } }; + default: { content: { "application/json": { message: string } } }; + }; + }; + post: { + requestBody: { + required: true; + content: { "application/json": { name: string } }; + }; + responses: { + 201: { content: { "application/json": { id: string } } }; + }; + }; + }; + "/users/{id}": { + get: { + parameters: { + path: { id: string }; + query: { expand?: boolean }; + }; + responses: { + 200: { content: { "application/json": { id: string; name: string } } }; + 404: { content: { "application/json": { message: string } } }; + }; + }; + delete: { + parameters: { + path: { id: string }; + }; + responses: { + 204: Record; + }; + }; + }; + "/health": { + get: { + responses: { + 200: { content: { "text/plain": "ok" } }; + }; + }; + }; +}; + +describe("typed create", () => { + const typedFetch = createFetch({ + fetch: () => + Promise.resolve( + new Response('{"users":[]}', { + status: 200, + headers: { "content-type": "application/json" }, + }) + ), + }); + + it("infers path, method, body and response types", () => { + // GET /users — default response should not include `default` error branch + const _users: Promise<{ users: string[] }> = typedFetch("/users"); + + // POST /users — required body enforced, response from 201 + const _created: Promise<{ id: string }> = typedFetch("/users", { + method: "POST", + body: { name: "Ada" }, + }); + + // GET with responseType override + const _health: Promise = typedFetch("/health", { + responseType: "text", + }); + + // GET /users/{id} — path params and optional query params + const _user: Promise<{ id: string; name: string }> = typedFetch( + "/users/{id}", + { pathParams: { id: "1" }, query: { expand: true } } + ); + + // DELETE /users/{id} — 204 no-content resolves to undefined + const _deleted: Promise = typedFetch("/users/{id}", { + method: "DELETE", + pathParams: { id: "1" }, + }); + + // @ts-expect-error Missing required request body for POST operation. + typedFetch("/users", { method: "POST" }); + + // @ts-expect-error PATCH is not an available method on /users. + typedFetch("/users", { method: "PATCH" }); + + // @ts-expect-error Path is not part of the schema. + typedFetch("/unknown"); + }); + + it("keeps legacy generic usage", () => { + const _legacy: Promise = typedFetch( + new Request("https://example.com") + ); + }); + + it("supports ofetch.create with schema type", () => { + const apiFetch = typedFetch.create({ baseURL: "/api" }); + const _users: Promise<{ users: string[] }> = apiFetch("/users"); + }); +});