diff --git a/.changeset/long-moose-scream.md b/.changeset/long-moose-scream.md new file mode 100644 index 000000000..5ed1d566d --- /dev/null +++ b/.changeset/long-moose-scream.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Add multipart/form-data request body support diff --git a/.changeset/wise-adults-deny.md b/.changeset/wise-adults-deny.md new file mode 100644 index 000000000..28cb5d647 --- /dev/null +++ b/.changeset/wise-adults-deny.md @@ -0,0 +1,5 @@ +--- +"openapi-fetch": minor +--- + +Breaking: openapi-fetch now just takes the first media type it finds rather than preferring JSON. This is because in the case of `multipart/form-data` vs `application/json`, it’s not inherently clear which you’d want. Or if there were multiple JSON-like media types. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 837d04b96..a437e77ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [18.x, 20.x] steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 diff --git a/docs/public/assets/openapi-schema.png b/docs/public/assets/openapi-schema.png index 0d24c8d2b..b9abd3819 100644 Binary files a/docs/public/assets/openapi-schema.png and b/docs/public/assets/openapi-schema.png differ diff --git a/docs/scripts/update-contributors.js b/docs/scripts/update-contributors.js index 15975480b..a9d9584ae 100644 --- a/docs/scripts/update-contributors.js +++ b/docs/scripts/update-contributors.js @@ -89,7 +89,7 @@ const OPENAPI_TS_CONTRIBUTORS = [ ]), ]; -export const OPENAPI_FETCH_CONTRIBUTORS = [...new Set(["drwpow", "fergusean", "shinzui", "ezpuzz", "KotoriK", "fletchertyler914", "nholik", "roj1512", "nickcaballero", "hd-o", "kecrily"])]; +export const OPENAPI_FETCH_CONTRIBUTORS = [...new Set(["drwpow", "fergusean", "shinzui", "ezpuzz", "KotoriK", "fletchertyler914", "nholik", "roj1512", "nickcaballero", "hd-o", "kecrily", "psychedelicious"])]; async function main() { const openapiTS = Promise.all(OPENAPI_TS_CONTRIBUTORS.map(async (username) => ({ username, avatar: await fetchAvatar(username) }))); diff --git a/docs/src/content/docs/openapi-fetch/api.md b/docs/src/content/docs/openapi-fetch/api.md index 445f3afef..ef9665a65 100644 --- a/docs/src/content/docs/openapi-fetch/api.md +++ b/docs/src/content/docs/openapi-fetch/api.md @@ -11,11 +11,13 @@ description: openapi-fetch API createClient(options); ``` -| Name | Type | Description | -| :-------------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | `string` | Prefix all fetch URLs with this option (e.g. `"https://myapi.dev/v1/"`). | -| `fetch` | `fetch` | Fetch function used for requests (defaults to `globalThis.fetch`) | -| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | +| Name | Type | Description | +| :---------------- | :-------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | Prefix all fetch URLs with this option (e.g. `"https://myapi.dev/v1/"`). | +| `fetch` | `fetch` | Fetch function used for requests (defaults to `globalThis.fetch`) | +| `querySerializer` | QuerySerializer | (optional) Serialize query params for all requests (default: `new URLSearchParams()`) | +| `bodySerializer` | BodySerializer | (optional) Serialize request body object for all requests (default: `JSON.stringify()`) | +| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | ## Fetch options @@ -25,35 +27,56 @@ The following options apply to all request methods (`.get()`, `.post()`, etc.) client.get("/my-url", options); ``` -| Name | Type | Description | -| :---------------- | :---------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `params` | ParamsObject | Provide `path` and `query` params from the OpenAPI schema | -| `params.path` | `{ [name]: value }` | Provide all `path` params (params that are part of the URL) | -| `params.query` | `{ [name]: value }` | Provide all `query params (params that are part of the searchParams | -| `body` | `{ [name]:value }` | The requestBody data, if needed (PUT/POST/PATCH/DEL only) | -| `querySerializer` | QuerySerializer | (optional) Override default param serialization (see [Parameter Serialization](#parameter-serialization)) | -| `parseAs` | `"json"` \| `"text"` \| `"arrayBuffer"` \| `"blob"` \| `"stream"` | Decide how to parse the response body, with `"stream"` skipping processing altogether (default: `"json"`) | -| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | +| Name | Type | Description | +| :---------------- | :---------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `params` | ParamsObject | Provide `path` and `query` params from the OpenAPI schema | +| `params.path` | `{ [name]: value }` | Provide all `path` params (params that are part of the URL) | +| `params.query` | `{ [name]: value }` | Provide all `query params (params that are part of the searchParams | +| `body` | `{ [name]:value }` | The requestBody data, if needed (PUT/POST/PATCH/DEL only) | +| `querySerializer` | QuerySerializer | (optional) Serialize query params for this request only (default: `new URLSearchParams()`) | +| `bodySerializer` | BodySerializer | (optional) Serialize request body for this request only (default: `JSON.stringify()`) | +| `parseAs` | `"json"` \| `"text"` \| `"arrayBuffer"` \| `"blob"` \| `"stream"` | Parse the response body, with `"stream"` skipping processing altogether (default: `"json"`) | +| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | -### Parameter Serialization +### querySerializer -In the spirit of being lightweight, this library only uses URLSearchParams to serialize parameters. So for complex query param types (e.g. arrays) you’ll need to provide your own `querySerializer()` method that transforms query params into a URL-safe string: +This library uses URLSearchParams to serialize query parameters. For complex query param types (e.g. arrays) you’ll need to provide your own `querySerializer()` method that transforms query params into a URL-safe string: ```ts -import createClient from "openapi-fetch"; -import { paths } from "./v1"; // generated from openapi-typescript - -const { get, post } = createClient({ baseUrl: "https://myapi.dev/v1/" }); - -const { data, error } = await get("/blogposts/{post_id}", { +const { data, error } = await get("/search", { params: { - path: { post_id: "my-post" }, - query: { version: 2 }, + query: { tags: ["food", "california", "healthy"] }, + }, + querySerializer(q) { + let s = ""; + for (const [k, v] of Object.entries(q)) { + if (Array.isArray(v)) { + s += `${k}[]=${v.join(",")}`; + } else { + s += `${k}=${v}`; + } + } + return s; // ?tags[]=food&tags[]=california&tags[]=healthy }, - querySerializer: (q) => `v=${q.version}`, // ✅ Still typechecked based on the URL! }); ``` -Note that this happens **at the request level** so that you still get correct type inference for that URL’s specific query params. +### bodySerializer + +Similar to [querySerializer](#querySerializer), bodySerializer works for requestBody. You probably only need this when using `multipart/form-data`: -_Thanks, [@ezpuzz](https://github.com/ezpuzz)!_ +```ts +const { data, error } = await put("/submit", { + body: { + name: "", + query: { version: 2 }, + }, + bodySerializer(body) { + const fd = new FormData(); + for (const [k, v] of Object.entries(body)) { + fd.append(k, v); + } + return fd; + }, +}); +``` diff --git a/docs/src/content/docs/openapi-fetch/index.md b/docs/src/content/docs/openapi-fetch/index.md index 7b44d0cac..2cbd0055e 100644 --- a/docs/src/content/docs/openapi-fetch/index.md +++ b/docs/src/content/docs/openapi-fetch/index.md @@ -5,7 +5,7 @@ description: Get Started with openapi-fetch openapi-fetch -openapi-fetch is an ultra-fast fetch client for TypeScript using your OpenAPI schema. Weighs in at **1 kb** and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS. +openapi-fetch applies your OpenAPI types to the native fetch API via TypeScript. Weighs in at **1 kb** and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS. | Library | Size (min) | | :----------------------------- | ---------: | @@ -19,7 +19,7 @@ The syntax is inspired by popular libraries like react-query or Apollo client, b import createClient from "openapi-fetch"; import { paths } from "./v1"; // generated from openapi-typescript -const { get, post } = createClient({ baseUrl: "https://myapi.dev/v1/" }); +const { get, put } = createClient({ baseUrl: "https://myapi.dev/v1/" }); // Type-checked request await put("/blogposts", { @@ -30,8 +30,7 @@ await put("/blogposts", { }); // Type-checked response -const { data, error } = await get("/blogposts/my-blog-post"); - +const { data, error } = await get("/blogposts/{post_id}", { params: { path: { post_id: "123" } } }); console.log(data.title); // ❌ 'data' is possibly 'undefined' console.log(error.message); // ❌ 'error' is possibly 'undefined' console.log(data?.foo); // ❌ Property 'foo' does not exist on type … @@ -81,17 +80,17 @@ And run `npm run test:ts` in your CI to catch type errors. ## Usage -Using **openapi-fetch** is as easy as reading your schema! For example, given the following schema: +Using **openapi-fetch** is as easy as reading your schema: ![OpenAPI schema example](/assets/openapi-schema.png) -Here’s how you’d fetch GET `/blogposts/{post_id}` and POST `/blogposts`: +Here’s how you’d fetch GET `/blogposts/{post_id}` and PUT `/blogposts`: ```ts import createClient from "openapi-fetch"; import { paths } from "./v1"; -const { get, post } = createClient({ baseUrl: "https://myapi.dev/v1/" }); +const { get, put } = createClient({ baseUrl: "https://myapi.dev/v1/" }); const { data, error } = await get("/blogposts/{post_id}", { params: { @@ -100,7 +99,7 @@ const { data, error } = await get("/blogposts/{post_id}", { }, }); -const { data, error } = await post("/blogposts", { +const { data, error } = await put("/blogposts", { body: { title: "New Post", body: "

New post body

", @@ -134,3 +133,11 @@ All methods return an object with **data**, **error**, and **response**. - **error** likewise contains that endpoint’s `4xx`/`5xx` response if the server returned either; otherwise it will be `undefined` - _Note: `default` will also be interpreted as `error`, since its intent is handling unexpected HTTP codes_ - **response** has response info like `status`, `headers`, etc. It is not typechecked. + +## Version Support + +openapi-fetch implements the [native fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) which is available in all major browsers. + +If using in a Node.js environment, version 18 or greater is recommended (newer is better). + +TypeScript support is pretty far-reaching as this library doesn’t use any cutting-edge features, but using the latest version of TypeScript is always recommended for accuracy. diff --git a/docs/src/data/contributors.json b/docs/src/data/contributors.json index ea2fbd996..29f02f19b 100644 --- a/docs/src/data/contributors.json +++ b/docs/src/data/contributors.json @@ -1 +1 @@ -{"openapi-typescript":[{"username":"drwpow","avatar":"https://avatars.githubusercontent.com/u/1369770?v=4?s=400"},{"username":"psmyrdek","avatar":"https://avatars.githubusercontent.com/u/6187417?v=4?s=400"},{"username":"enmand","avatar":"https://avatars.githubusercontent.com/u/432487?v=4?s=400"},{"username":"atlefren","avatar":"https://avatars.githubusercontent.com/u/1829927?v=4?s=400"},{"username":"tpdewolf","avatar":"https://avatars.githubusercontent.com/u/4455209?v=4?s=400"},{"username":"tombarton","avatar":"https://avatars.githubusercontent.com/u/6222711?v=4?s=400"},{"username":"svnv","avatar":"https://avatars.githubusercontent.com/u/1080888?v=4?s=400"},{"username":"sorin-davidoi","avatar":"https://avatars.githubusercontent.com/u/2109702?v=4?s=400"},{"username":"scvnathan","avatar":"https://avatars.githubusercontent.com/u/73474?v=4?s=400"},{"username":"lbenie","avatar":"https://avatars.githubusercontent.com/u/7316046?v=4?s=400"},{"username":"bokub","avatar":"https://avatars.githubusercontent.com/u/17952318?v=4?s=400"},{"username":"antonk52","avatar":"https://avatars.githubusercontent.com/u/5817809?v=4?s=400"},{"username":"tshelburne","avatar":"https://avatars.githubusercontent.com/u/1202267?v=4?s=400"},{"username":"mmiszy","avatar":"https://avatars.githubusercontent.com/u/1338731?v=4?s=400"},{"username":"skh-","avatar":"https://avatars.githubusercontent.com/u/1292598?v=4?s=400"},{"username":"BlooJeans","avatar":"https://avatars.githubusercontent.com/u/1751182?v=4?s=400"},{"username":"selbekk","avatar":"https://avatars.githubusercontent.com/u/1307267?v=4?s=400"},{"username":"Mause","avatar":"https://avatars.githubusercontent.com/u/1405026?v=4?s=400"},{"username":"henhal","avatar":"https://avatars.githubusercontent.com/u/9608258?v=4?s=400"},{"username":"gr2m","avatar":"https://avatars.githubusercontent.com/u/39992?v=4?s=400"},{"username":"samdbmg","avatar":"https://avatars.githubusercontent.com/u/408983?v=4?s=400"},{"username":"rendall","avatar":"https://avatars.githubusercontent.com/u/293263?v=4?s=400"},{"username":"robertmassaioli","avatar":"https://avatars.githubusercontent.com/u/149178?v=4?s=400"},{"username":"jankuca","avatar":"https://avatars.githubusercontent.com/u/367262?v=4?s=400"},{"username":"th-m","avatar":"https://avatars.githubusercontent.com/u/13792029?v=4?s=400"},{"username":"asithade","avatar":"https://avatars.githubusercontent.com/u/3814354?v=4?s=400"},{"username":"MikeYermolayev","avatar":"https://avatars.githubusercontent.com/u/8783498?v=4?s=400"},{"username":"radist2s","avatar":"https://avatars.githubusercontent.com/u/725645?v=4?s=400"},{"username":"FedeBev","avatar":"https://avatars.githubusercontent.com/u/22151395?v=4?s=400"},{"username":"yamacent","avatar":"https://avatars.githubusercontent.com/u/8544439?v=4?s=400"},{"username":"dnalborczyk","avatar":"https://avatars.githubusercontent.com/u/2903325?v=4?s=400"},{"username":"FabioWanner","avatar":"https://avatars.githubusercontent.com/u/46821078?v=4?s=400"},{"username":"ashsmith","avatar":"https://avatars.githubusercontent.com/u/1086841?v=4?s=400"},{"username":"mehalter","avatar":"https://avatars.githubusercontent.com/u/1591837?v=4?s=400"},{"username":"Chrg1001","avatar":"https://avatars.githubusercontent.com/u/40189653?v=4?s=400"},{"username":"sharmarajdaksh","avatar":"https://avatars.githubusercontent.com/u/33689528?v=4?s=400"},{"username":"shuluster","avatar":"https://avatars.githubusercontent.com/u/1707910?v=4?s=400"},{"username":"FDiskas","avatar":"https://avatars.githubusercontent.com/u/468006?v=4?s=400"},{"username":"ericzorn93","avatar":"https://avatars.githubusercontent.com/u/22532542?v=4?s=400"},{"username":"mbelsky","avatar":"https://avatars.githubusercontent.com/u/3923527?v=4?s=400"},{"username":"Peteck","avatar":"https://avatars.githubusercontent.com/u/129566390?v=4?s=400"},{"username":"rustyconover","avatar":"https://avatars.githubusercontent.com/u/731941?v=4?s=400"},{"username":"bunkscene","avatar":"https://avatars.githubusercontent.com/u/2693678?v=4?s=400"},{"username":"ottomated","avatar":"https://avatars.githubusercontent.com/u/31470743?v=4?s=400"},{"username":"sadfsdfdsa","avatar":"https://avatars.githubusercontent.com/u/28733669?v=4?s=400"},{"username":"ajaishankar","avatar":"https://avatars.githubusercontent.com/u/328008?v=4?s=400"},{"username":"dominikdosoudil","avatar":"https://avatars.githubusercontent.com/u/15929942?v=4?s=400"},{"username":"kgtkr","avatar":"https://avatars.githubusercontent.com/u/17868838?v=4?s=400"},{"username":"berzi","avatar":"https://avatars.githubusercontent.com/u/32619123?v=4?s=400"},{"username":"PhilipTrauner","avatar":"https://avatars.githubusercontent.com/u/9287847?v=4?s=400"},{"username":"Powell-v2","avatar":"https://avatars.githubusercontent.com/u/25308326?v=4?s=400"},{"username":"duncanbeevers","avatar":"https://avatars.githubusercontent.com/u/7367?v=4?s=400"},{"username":"tkukushkin","avatar":"https://avatars.githubusercontent.com/u/1482516?v=4?s=400"},{"username":"Semigradsky","avatar":"https://avatars.githubusercontent.com/u/1198848?v=4?s=400"},{"username":"MrLeebo","avatar":"https://avatars.githubusercontent.com/u/2754163?v=4?s=400"},{"username":"axelhzf","avatar":"https://avatars.githubusercontent.com/u/175627?v=4?s=400"},{"username":"imagoiq","avatar":"https://avatars.githubusercontent.com/u/12294151?v=4?s=400"},{"username":"BTMPL","avatar":"https://avatars.githubusercontent.com/u/247153?v=4?s=400"},{"username":"HiiiiD","avatar":"https://avatars.githubusercontent.com/u/61231210?v=4?s=400"},{"username":"yacinehmito","avatar":"https://avatars.githubusercontent.com/u/6893840?v=4?s=400"},{"username":"sajadtorkamani","avatar":"https://avatars.githubusercontent.com/u/9380313?v=4?s=400"},{"username":"mvdbeek","avatar":"https://avatars.githubusercontent.com/u/6804901?v=4?s=400"},{"username":"sgrimm","avatar":"https://avatars.githubusercontent.com/u/1248649?v=4?s=400"},{"username":"Swiftwork","avatar":"https://avatars.githubusercontent.com/u/455178?v=4?s=400"},{"username":"mtth","avatar":"https://avatars.githubusercontent.com/u/1216372?v=4?s=400"},{"username":"mitchell-merry","avatar":"https://avatars.githubusercontent.com/u/8567231?v=4?s=400"},{"username":"qnp","avatar":"https://avatars.githubusercontent.com/u/6012554?v=4?s=400"},{"username":"shoffmeister","avatar":"https://avatars.githubusercontent.com/u/3868036?v=4?s=400"},{"username":"liangskyli","avatar":"https://avatars.githubusercontent.com/u/31531283?v=4?s=400"},{"username":"happycollision","avatar":"https://avatars.githubusercontent.com/u/3663628?v=4?s=400"},{"username":"barakalon","avatar":"https://avatars.githubusercontent.com/u/12398927?v=4?s=400"},{"username":"pvanagtmaal","avatar":"https://avatars.githubusercontent.com/u/5946464?v=4?s=400"}],"openapi-fetch":[{"username":"drwpow","avatar":"https://avatars.githubusercontent.com/u/1369770?v=4?s=400"},{"username":"fergusean","avatar":"https://avatars.githubusercontent.com/u/1029297?v=4?s=400"},{"username":"shinzui","avatar":"https://avatars.githubusercontent.com/u/519?v=4?s=400"},{"username":"ezpuzz","avatar":"https://avatars.githubusercontent.com/u/672182?v=4?s=400"},{"username":"KotoriK","avatar":"https://avatars.githubusercontent.com/u/52659125?v=4?s=400"},{"username":"fletchertyler914","avatar":"https://avatars.githubusercontent.com/u/3344498?v=4?s=400"},{"username":"nholik","avatar":"https://avatars.githubusercontent.com/u/2022214?v=4?s=400"},{"username":"roj1512","avatar":"https://avatars.githubusercontent.com/u/49933115?v=4?s=400"},{"username":"nickcaballero","avatar":"https://avatars.githubusercontent.com/u/355976?v=4?s=400"},{"username":"hd-o","avatar":"https://avatars.githubusercontent.com/u/58871222?v=4?s=400"},{"username":"kecrily","avatar":"https://avatars.githubusercontent.com/u/45708948?v=4?s=400"}]} \ No newline at end of file +{"openapi-typescript":[{"username":"drwpow","avatar":"https://avatars.githubusercontent.com/u/1369770?v=4?s=400"},{"username":"psmyrdek","avatar":"https://avatars.githubusercontent.com/u/6187417?v=4?s=400"},{"username":"enmand","avatar":"https://avatars.githubusercontent.com/u/432487?v=4?s=400"},{"username":"atlefren","avatar":"https://avatars.githubusercontent.com/u/1829927?v=4?s=400"},{"username":"tpdewolf","avatar":"https://avatars.githubusercontent.com/u/4455209?v=4?s=400"},{"username":"tombarton","avatar":"https://avatars.githubusercontent.com/u/6222711?v=4?s=400"},{"username":"svnv","avatar":"https://avatars.githubusercontent.com/u/1080888?v=4?s=400"},{"username":"sorin-davidoi","avatar":"https://avatars.githubusercontent.com/u/2109702?v=4?s=400"},{"username":"scvnathan","avatar":"https://avatars.githubusercontent.com/u/73474?v=4?s=400"},{"username":"lbenie","avatar":"https://avatars.githubusercontent.com/u/7316046?v=4?s=400"},{"username":"bokub","avatar":"https://avatars.githubusercontent.com/u/17952318?v=4?s=400"},{"username":"antonk52","avatar":"https://avatars.githubusercontent.com/u/5817809?v=4?s=400"},{"username":"tshelburne","avatar":"https://avatars.githubusercontent.com/u/1202267?v=4?s=400"},{"username":"mmiszy","avatar":"https://avatars.githubusercontent.com/u/1338731?v=4?s=400"},{"username":"skh-","avatar":"https://avatars.githubusercontent.com/u/1292598?v=4?s=400"},{"username":"BlooJeans","avatar":"https://avatars.githubusercontent.com/u/1751182?v=4?s=400"},{"username":"selbekk","avatar":"https://avatars.githubusercontent.com/u/1307267?v=4?s=400"},{"username":"Mause","avatar":"https://avatars.githubusercontent.com/u/1405026?v=4?s=400"},{"username":"henhal","avatar":"https://avatars.githubusercontent.com/u/9608258?v=4?s=400"},{"username":"gr2m","avatar":"https://avatars.githubusercontent.com/u/39992?v=4?s=400"},{"username":"samdbmg","avatar":"https://avatars.githubusercontent.com/u/408983?v=4?s=400"},{"username":"rendall","avatar":"https://avatars.githubusercontent.com/u/293263?v=4?s=400"},{"username":"robertmassaioli","avatar":"https://avatars.githubusercontent.com/u/149178?v=4?s=400"},{"username":"jankuca","avatar":"https://avatars.githubusercontent.com/u/367262?v=4?s=400"},{"username":"th-m","avatar":"https://avatars.githubusercontent.com/u/13792029?v=4?s=400"},{"username":"asithade","avatar":"https://avatars.githubusercontent.com/u/3814354?v=4?s=400"},{"username":"MikeYermolayev","avatar":"https://avatars.githubusercontent.com/u/8783498?v=4?s=400"},{"username":"radist2s","avatar":"https://avatars.githubusercontent.com/u/725645?v=4?s=400"},{"username":"FedeBev","avatar":"https://avatars.githubusercontent.com/u/22151395?v=4?s=400"},{"username":"yamacent","avatar":"https://avatars.githubusercontent.com/u/8544439?v=4?s=400"},{"username":"dnalborczyk","avatar":"https://avatars.githubusercontent.com/u/2903325?v=4?s=400"},{"username":"FabioWanner","avatar":"https://avatars.githubusercontent.com/u/46821078?v=4?s=400"},{"username":"ashsmith","avatar":"https://avatars.githubusercontent.com/u/1086841?v=4?s=400"},{"username":"mehalter","avatar":"https://avatars.githubusercontent.com/u/1591837?v=4?s=400"},{"username":"Chrg1001","avatar":"https://avatars.githubusercontent.com/u/40189653?v=4?s=400"},{"username":"sharmarajdaksh","avatar":"https://avatars.githubusercontent.com/u/33689528?v=4?s=400"},{"username":"shuluster","avatar":"https://avatars.githubusercontent.com/u/1707910?v=4?s=400"},{"username":"FDiskas","avatar":"https://avatars.githubusercontent.com/u/468006?v=4?s=400"},{"username":"ericzorn93","avatar":"https://avatars.githubusercontent.com/u/22532542?v=4?s=400"},{"username":"mbelsky","avatar":"https://avatars.githubusercontent.com/u/3923527?v=4?s=400"},{"username":"Peteck","avatar":"https://avatars.githubusercontent.com/u/129566390?v=4?s=400"},{"username":"rustyconover","avatar":"https://avatars.githubusercontent.com/u/731941?v=4?s=400"},{"username":"bunkscene","avatar":"https://avatars.githubusercontent.com/u/2693678?v=4?s=400"},{"username":"ottomated","avatar":"https://avatars.githubusercontent.com/u/31470743?v=4?s=400"},{"username":"sadfsdfdsa","avatar":"https://avatars.githubusercontent.com/u/28733669?v=4?s=400"},{"username":"ajaishankar","avatar":"https://avatars.githubusercontent.com/u/328008?v=4?s=400"},{"username":"dominikdosoudil","avatar":"https://avatars.githubusercontent.com/u/15929942?v=4?s=400"},{"username":"kgtkr","avatar":"https://avatars.githubusercontent.com/u/17868838?v=4?s=400"},{"username":"berzi","avatar":"https://avatars.githubusercontent.com/u/32619123?v=4?s=400"},{"username":"PhilipTrauner","avatar":"https://avatars.githubusercontent.com/u/9287847?v=4?s=400"},{"username":"Powell-v2","avatar":"https://avatars.githubusercontent.com/u/25308326?v=4?s=400"},{"username":"duncanbeevers","avatar":"https://avatars.githubusercontent.com/u/7367?v=4?s=400"},{"username":"tkukushkin","avatar":"https://avatars.githubusercontent.com/u/1482516?v=4?s=400"},{"username":"Semigradsky","avatar":"https://avatars.githubusercontent.com/u/1198848?v=4?s=400"},{"username":"MrLeebo","avatar":"https://avatars.githubusercontent.com/u/2754163?v=4?s=400"},{"username":"axelhzf","avatar":"https://avatars.githubusercontent.com/u/175627?v=4?s=400"},{"username":"imagoiq","avatar":"https://avatars.githubusercontent.com/u/12294151?v=4?s=400"},{"username":"BTMPL","avatar":"https://avatars.githubusercontent.com/u/247153?v=4?s=400"},{"username":"HiiiiD","avatar":"https://avatars.githubusercontent.com/u/61231210?v=4?s=400"},{"username":"yacinehmito","avatar":"https://avatars.githubusercontent.com/u/6893840?v=4?s=400"},{"username":"sajadtorkamani","avatar":"https://avatars.githubusercontent.com/u/9380313?v=4?s=400"},{"username":"mvdbeek","avatar":"https://avatars.githubusercontent.com/u/6804901?v=4?s=400"},{"username":"sgrimm","avatar":"https://avatars.githubusercontent.com/u/1248649?v=4?s=400"},{"username":"Swiftwork","avatar":"https://avatars.githubusercontent.com/u/455178?v=4?s=400"},{"username":"mtth","avatar":"https://avatars.githubusercontent.com/u/1216372?v=4?s=400"},{"username":"mitchell-merry","avatar":"https://avatars.githubusercontent.com/u/8567231?v=4?s=400"},{"username":"qnp","avatar":"https://avatars.githubusercontent.com/u/6012554?v=4?s=400"},{"username":"shoffmeister","avatar":"https://avatars.githubusercontent.com/u/3868036?v=4?s=400"},{"username":"liangskyli","avatar":"https://avatars.githubusercontent.com/u/31531283?v=4?s=400"},{"username":"happycollision","avatar":"https://avatars.githubusercontent.com/u/3663628?v=4?s=400"},{"username":"barakalon","avatar":"https://avatars.githubusercontent.com/u/12398927?v=4?s=400"},{"username":"pvanagtmaal","avatar":"https://avatars.githubusercontent.com/u/5946464?v=4?s=400"}],"openapi-fetch":[{"username":"drwpow","avatar":"https://avatars.githubusercontent.com/u/1369770?v=4?s=400"},{"username":"fergusean","avatar":"https://avatars.githubusercontent.com/u/1029297?v=4?s=400"},{"username":"shinzui","avatar":"https://avatars.githubusercontent.com/u/519?v=4?s=400"},{"username":"ezpuzz","avatar":"https://avatars.githubusercontent.com/u/672182?v=4?s=400"},{"username":"KotoriK","avatar":"https://avatars.githubusercontent.com/u/52659125?v=4?s=400"},{"username":"fletchertyler914","avatar":"https://avatars.githubusercontent.com/u/3344498?v=4?s=400"},{"username":"nholik","avatar":"https://avatars.githubusercontent.com/u/2022214?v=4?s=400"},{"username":"roj1512","avatar":"https://avatars.githubusercontent.com/u/49933115?v=4?s=400"},{"username":"nickcaballero","avatar":"https://avatars.githubusercontent.com/u/355976?v=4?s=400"},{"username":"hd-o","avatar":"https://avatars.githubusercontent.com/u/58871222?v=4?s=400"},{"username":"kecrily","avatar":"https://avatars.githubusercontent.com/u/45708948?v=4?s=400"},{"username":"psychedelicious","avatar":"https://avatars.githubusercontent.com/u/4822129?v=4?s=400"}]} \ No newline at end of file diff --git a/packages/openapi-fetch/README.md b/packages/openapi-fetch/README.md index a90dce5d8..31056ae1e 100644 --- a/packages/openapi-fetch/README.md +++ b/packages/openapi-fetch/README.md @@ -1,6 +1,6 @@ openapi-fetch -openapi-fetch is an ultra-fast fetch client for TypeScript using your OpenAPI schema. Weighs in at **1 kb** and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS. +openapi-fetch applies your OpenAPI types to the native fetch API via TypeScript. Weighs in at **1 kb** and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS. | Library | Size (min) | | :----------------------------- | ---------: | @@ -76,17 +76,17 @@ And run `npm run test:ts` in your CI to catch type errors. ## 🏓 Usage -Using **openapi-fetch** is as easy as reading your schema! For example, given the following schema: +Using **openapi-fetch** is as easy as reading your schema: -![OpenAPI schema example](../../../docs/public/assets/openapi-schema.png) +![OpenAPI schema example](../../docs/public/assets/openapi-schema.png) -Here’s how you’d fetch GET `/blogpost/{post_id}` and POST `/blogposts`: +Here’s how you’d fetch GET `/blogposts/{post_id}` and PUT `/blogposts`: ```ts import createClient from "openapi-fetch"; import { paths } from "./v1"; -const { get, post } = createClient({ baseUrl: "https://myapi.dev/v1/" }); +const { get, put } = createClient({ baseUrl: "https://myapi.dev/v1/" }); const { data, error } = await get("/blogposts/{post_id}", { params: { @@ -95,7 +95,7 @@ const { data, error } = await get("/blogposts/{post_id}", { }, }); -const { data, error } = await post("/blogposts", { +const { data, error } = await put("/blogposts", { body: { title: "New Post", body: "

New post body

", @@ -130,6 +130,14 @@ All methods return an object with **data**, **error**, and **response**. - _Note: `default` will also be interpreted as `error`, since its intent is handling unexpected HTTP codes_ - **response** has response info like `status`, `headers`, etc. It is not typechecked. +## Version Support + +openapi-fetch implements the [native fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) which is available in all major browsers. + +If using in a Node.js environment, version 18 or greater is recommended (newer is better). + +TypeScript support is pretty far-reaching as this library doesn’t use any cutting-edge features, but using the latest version of TypeScript is always recommended for accuracy. + ## API ### Create Client @@ -140,147 +148,73 @@ All methods return an object with **data**, **error**, and **response**. createClient(options); ``` -| Name | Type | Description | -| :-------------- | :------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | `string` | Prefix all fetch URLs with this option (e.g. `"https://myapi.dev/v1/"`). | -| `fetch` | `fetch` | Fetch function used for requests (defaults to `globalThis.fetch`) | -| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | +| Name | Type | Description | +| :---------------- | :-------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | `string` | Prefix all fetch URLs with this option (e.g. `"https://myapi.dev/v1/"`). | +| `fetch` | `fetch` | Fetch function used for requests (defaults to `globalThis.fetch`) | +| `querySerializer` | QuerySerializer | (optional) Serialize query params for all requests (default: `new URLSearchParams()`) | +| `bodySerializer` | BodySerializer | (optional) Serialize request body object for all requests (default: `JSON.stringify()`) | +| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | ### Fetch options -```ts -import { paths } from "./v1"; - -const { get, put, post, del, options, head, patch, trace } = createClient({ baseUrl: "https://myapi.dev/v1/" }); - -const { data, error, response } = await get("/my-url", options); -``` - -| Name | Type | Description | -| :---------------- | :-----------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `params` | ParamsObject | Provide `path` and `query` params from the OpenAPI schema | -| `params.path` | `{ [name]: value }` | Provide all `path` params (params that are part of the URL) | -| `params.query` | `{ [name]: value }` | Provide all `query params (params that are part of the searchParams | -| `body` | `{ [name]:value }` | The requestBody data, if needed (PUT/POST/PATCH/DEL only) | -| `querySerializer` | QuerySerializer | (optional) Override default param serialization (see [Parameter Serialization](#parameter-serialization)) | -| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | - -#### 🔀 Parameter Serialization - -In the spirit of being lightweight, this library only uses URLSearchParams to serialize parameters. So for complex query param types (e.g. arrays) you’ll need to provide your own `querySerializer()` method that transforms query params into a URL-safe string: +The following options apply to all request methods (`.get()`, `.post()`, etc.) ```ts -import createClient from "openapi-fetch"; -import { paths } from "./v1"; // generated from openapi-typescript - -const { get, post } = createClient({ baseUrl: "https://myapi.dev/v1/" }); - -const { data, error } = await get("/post/{post_id}", { - params: { - path: { post_id: "my-post" }, - query: { version: 2 }, - }, - querySerializer: (q) => `v=${q.version}`, // ✅ Still typechecked based on the URL! -}); +client.get("/my-url", options); ``` -Note that this happens **at the request level** so that you still get correct type inference for that URL’s specific query params. +| Name | Type | Description | +| :---------------- | :---------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `params` | ParamsObject | Provide `path` and `query` params from the OpenAPI schema | +| `params.path` | `{ [name]: value }` | Provide all `path` params (params that are part of the URL) | +| `params.query` | `{ [name]: value }` | Provide all `query params (params that are part of the searchParams | +| `body` | `{ [name]:value }` | The requestBody data, if needed (PUT/POST/PATCH/DEL only) | +| `querySerializer` | QuerySerializer | (optional) Serialize query params for this request only (default: `new URLSearchParams()`) | +| `bodySerializer` | BodySerializer | (optional) Serialize request body for this request only (default: `JSON.stringify()`) | +| `parseAs` | `"json"` \| `"text"` \| `"arrayBuffer"` \| `"blob"` \| `"stream"` | Parse the response body, with `"stream"` skipping processing altogether (default: `"json"`) | +| (Fetch options) | | Any valid fetch option (`headers`, `mode`, `cache`, `signal` …) (docs) | -_Thanks, [@ezpuzz](https://github.com/ezpuzz)!_ +### querySerializer -Provide a `querySerializer()` to `createClient()` to globally override the default `URLSearchParams` serializer. Serializers provided to a specific request method still override the global default. +This library uses URLSearchParams to serialize query parameters. For complex query param types (e.g. arrays) you’ll need to provide your own `querySerializer()` method that transforms query params into a URL-safe string: ```ts -import createClient, { defaultSerializer } from "openapi-fetch"; -import { paths } from "./v1"; // generated from openapi-typescript -import { queryString } from "query-string"; - -const { get, post } = createClient({ - baseUrl: "https://myapi.dev/v1/", - querySerializer: (q) => queryString.stringify(q, { arrayFormat: "none" }), // Override the default `URLSearchParams` serializer -}); - -const { data, error } = await get("/posts/", { +const { data, error } = await get("/search", { params: { - query: { categories: ["dogs", "cats", "lizards"] }, // Use the serializer specified in `createClient()` + query: { tags: ["food", "california", "healthy"] }, }, -}); - -const { data, error } = await get("/images/{image_id}", { - params: { - path: { image_id: "image-id" }, - query: { size: 512 }, + querySerializer(q) { + let s = ""; + for (const [k, v] of Object.entries(q)) { + if (Array.isArray(v)) { + s += `${k}[]=${v.join(",")}`; + } else { + s += `${k}=${v}`; + } + } + return s; // ?tags[]=food&tags[]=california&tags[]=healthy }, - querySerializer: defaultSerializer, // Use `openapi-fetch`'s `URLSearchParams` serializer }); ``` -_Thanks, [@psychedelicious](https://github.com/psychedelicious)!_ - -## Examples - -### 🔒 Handling Auth - -Authentication often requires some reactivity dependent on a token. Since this library is so low-level, there are myriad ways to handle it: +### bodySerializer -#### Nano Stores - -Here’s how it can be handled using [Nano Stores](https://github.com/nanostores/nanostores), a tiny (334 b), universal signals store: +Similar to [querySerializer](#querySerializer), bodySerializer works for requestBody. You probably only need this when using `multipart/form-data`: ```ts -// src/lib/api/index.ts -import { atom, computed } from "nanostores"; -import createClient from "openapi-fetch"; -import { paths } from "./v1"; - -export const authToken = atom(); -someAuthMethod().then((newToken) => authToken.set(newToken)); - -export const client = computed(authToken, (currentToken) => - createClient({ - headers: currentToken ? { Authorization: `Bearer ${currentToken}` } : {}, - baseUrl: "https://myapi.dev/v1/", - }) -); - -// src/some-other-file.ts -import { client } from "./lib/api"; - -const { get, post } = client.get(); - -get("/some-authenticated-url", { - /* … */ -}); -``` - -#### Vanilla JS Proxies - -You can also use [proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) which are now supported in all modern browsers: - -```ts -// src/lib/api/index.ts -import createClient from "openapi-fetch"; -import { paths } from "./v1"; - -let authToken: string | undefined = undefined; -someAuthMethod().then((newToken) => (authToken = newToken)); - -const baseClient = createClient({ baseUrl: "https://myapi.dev/v1/" }); -export default new Proxy(baseClient, { - get(_, key: keyof typeof baseClient) { - const newClient = createClient({ - headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, - baseUrl: "https://myapi.dev/v1/", - }); - return newClient[key]; +const { data, error } = await put("/submit", { + body: { + name: "", + query: { version: 2 }, + }, + bodySerializer(body) { + const fd = new FormData(); + for (const [k, v] of Object.entries(body)) { + fd.append(k, v); + } + return fd; }, -}); - -// src/some-other-file.ts -import client from "./lib/api"; - -client.get("/some-authenticated-url", { - /* … */ }); ``` diff --git a/packages/openapi-fetch/package.json b/packages/openapi-fetch/package.json index 38803a720..112b0c735 100644 --- a/packages/openapi-fetch/package.json +++ b/packages/openapi-fetch/package.json @@ -1,6 +1,6 @@ { "name": "openapi-fetch", - "description": "Ultra-fast fetching for TypeScript generated automatically from your OpenAPI schema. Weighs in at 1 kb and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS.", + "description": "Fetch using your OpenAPI types. Weighs in at 1 kb and has virtually zero runtime. Works with React, Vue, Svelte, or vanilla JS.", "version": "0.5.0", "author": { "name": "Drew Powers", diff --git a/packages/openapi-fetch/src/index.test.ts b/packages/openapi-fetch/src/index.test.ts index 1935a63bf..04e09101b 100644 --- a/packages/openapi-fetch/src/index.test.ts +++ b/packages/openapi-fetch/src/index.test.ts @@ -257,12 +257,43 @@ describe("client", () => { const client = createClient(); mockFetchOnce({ status: 200, body: "{}" }); await client.get("/blogposts/{post_id}", { - params: { path: { post_id: "post?id = 🥴" }, query: {} }, + params: { + path: { + post_id: "post?id = 🥴", + }, + }, }); // expect post_id to be encoded properly expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/post%3Fid%20%3D%20%F0%9F%A5%B4"); }); + + it("multipart/form-data", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.put("/contact", { + headers: { + "Content-Type": "multipart/form-data", + }, + body: { + name: "John Doe", + email: "test@email.email", + subject: "Test Message", + message: "This is a test message", + }, + bodySerializer(body) { + const fd = new FormData(); + for (const [k, v] of Object.entries(body)) { + fd.append(k, v); + } + return fd; + }, + }); + + // expect post_id to be encoded properly + const req = fetchMocker.mock.calls[0][1]; + expect(req.body).toBeInstanceOf(FormData); + }); }); describe("responses", () => { @@ -294,17 +325,6 @@ describe("client", () => { expect(error.message).toBe("An unexpected error occurred"); }); - it("falls back to text() on invalid JSON", async () => { - const client = createClient(); - const bodyResponse = "My Post"; - mockFetchOnce({ status: 200, body: bodyResponse }); - const { data, error } = await client.get("/blogposts/{post_id}", { params: { path: { post_id: "my-post" } } }); - if (error) throw new Error("falls back to text(): error shouldn’t be present"); - - // assert `data` is a string - expect(data).toBe(bodyResponse); - }); - describe("parseAs", () => { it("text", async () => { const client = createClient(); @@ -498,7 +518,6 @@ describe("client", () => { const client = createClient(); mockFetchOnce({ status: 201, body: JSON.stringify(mockData) }); const { data, error, response } = await client.put("/blogposts", { - params: {}, body: { title: "New Post", body: "

Best post yet

", diff --git a/packages/openapi-fetch/src/index.ts b/packages/openapi-fetch/src/index.ts index 54fff0ee1..d718d9290 100644 --- a/packages/openapi-fetch/src/index.ts +++ b/packages/openapi-fetch/src/index.ts @@ -12,6 +12,8 @@ interface ClientOptions extends RequestInit { fetch?: typeof fetch; /** global querySerializer */ querySerializer?: QuerySerializer; + /** global bodySerializer */ + bodySerializer?: BodySerializer; } export interface BaseParams { params?: { query?: Record }; @@ -38,38 +40,48 @@ export type PathsWith, PathnameMeth }[keyof Paths]; /** Find first match of multiple keys */ export type FilterKeys = { [K in keyof Obj]: K extends Matchers ? Obj[K] : never }[keyof Obj]; -/** handle "application/json", "application/vnd.api+json", "appliacation/json;charset=utf-8" and more */ -export type JSONLike = `${string}json${string}`; +export type MediaType = `${string}/${string}`; // general purpose types -export type Params = O extends { parameters: any } ? { params: NonNullable } : BaseParams; -export type RequestBodyObj = O extends { requestBody?: any } ? O["requestBody"] : never; -export type RequestBodyContent = undefined extends RequestBodyObj ? FilterKeys>, "content"> | undefined : FilterKeys, "content">; -export type RequestBodyJSON = FilterKeys, JSONLike> extends never ? FilterKeys>, JSONLike> | undefined : FilterKeys, JSONLike>; -export type RequestBody = undefined extends RequestBodyJSON ? { body?: RequestBodyJSON } : { body: RequestBodyJSON }; -export type QuerySerializer = (query: O extends { parameters: any } ? NonNullable : Record) => string; -export type RequestOptions = Params & RequestBody & { querySerializer?: QuerySerializer; parseAs?: ParseAs }; -export type Success = FilterKeys, "content">; -export type Error = FilterKeys, "content">; +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 = 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 & { + 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, JSONLike>> : unknown; error?: never; response: Response } - | { data?: never; error: T extends { responses: any } ? NonNullable, JSONLike>> : unknown; response: Response }; + | { data: T extends { responses: any } ? NonNullable, MediaType>> : unknown; error?: never; response: Response } + | { data?: never; error: T extends { responses: any } ? NonNullable, MediaType>> : unknown; response: Response }; -/** Call URLSearchParams() on the object, but remove `undefined` and `null` params */ -export function defaultSerializer(q: unknown): string { +/** serialize query params to string */ +export function defaultQuerySerializer(q: T): string { const search = new URLSearchParams(); if (q && typeof q === "object") { for (const [k, v] of Object.entries(q)) { if (v === undefined || v === null) continue; - search.set(k, String(v)); + search.set(k, v); } } return search.toString(); } +/** serialize body object to string */ +export function defaultBodySerializer(body: T): string { + return JSON.stringify(body); +} + /** Construct URL string from baseUrl and handle path and query params */ export function createFinalURL(url: string, options: { baseUrl?: string; params: { query?: Record; path?: Record }; querySerializer: QuerySerializer }): string { let finalURL = `${options.baseUrl ? options.baseUrl.replace(TRAILING_SLASH_RE, "") : ""}${url as string}`; @@ -84,7 +96,7 @@ export function createFinalURL(url: string, options: { baseUrl?: string; para } export default function createClient(clientOptions: ClientOptions = {}) { - const { fetch = globalThis.fetch, querySerializer: globalQuerySerializer, ...options } = clientOptions; + const { fetch = globalThis.fetch, querySerializer: globalQuerySerializer, bodySerializer: globalBodySerializer, ...options } = clientOptions; const defaultHeaders = new Headers({ ...DEFAULT_HEADERS, @@ -92,7 +104,7 @@ export default function createClient(clientOptions: ClientOpti }); async function coreFetch

(url: P, fetchOptions: FetchOptions): Promise> { - const { headers, body: requestBody, params = {}, parseAs = "json", querySerializer = globalQuerySerializer ?? defaultSerializer, ...init } = fetchOptions || {}; + const { headers, body: requestBody, params = {}, parseAs = "json", querySerializer = globalQuerySerializer ?? defaultQuerySerializer, bodySerializer = globalBodySerializer ?? defaultBodySerializer, ...init } = fetchOptions || {}; // URL const finalURL = createFinalURL(url as string, { baseUrl: options.baseUrl, params, querySerializer }); @@ -106,13 +118,14 @@ export default function createClient(clientOptions: ClientOpti } // fetch! - const response = await fetch(finalURL, { + const requestInit: RequestInit = { redirect: "follow", ...options, ...init, headers: baseHeaders, - body: typeof requestBody === "string" ? requestBody : JSON.stringify(requestBody), - }); + }; + if (requestBody) requestInit.body = bodySerializer(requestBody as any); + const response = await fetch(finalURL, requestInit); // handle empty content // note: we return `{}` because we want user truthy checks for `.data` or `.error` to succeed @@ -124,11 +137,8 @@ export default function createClient(clientOptions: ClientOpti if (response.ok) { let data: any = response.body; if (parseAs !== "stream") { - try { - data = await response.clone()[parseAs](); - } catch { - data = await response.clone().text(); - } + const cloned = response.clone(); + data = typeof cloned[parseAs] === "function" ? await cloned[parseAs]() : await cloned.text(); } return { data, response }; } diff --git a/packages/openapi-fetch/test/v1.d.ts b/packages/openapi-fetch/test/v1.d.ts index 29f165cf5..60ed4d2ba 100644 --- a/packages/openapi-fetch/test/v1.d.ts +++ b/packages/openapi-fetch/test/v1.d.ts @@ -221,6 +221,14 @@ export interface paths { }; }; }; + "/contact": { + put: { + requestBody: components["requestBodies"]["Contact"]; + responses: { + 200: components["responses"]["Contact"]; + }; + }; + }; } export type webhooks = Record; @@ -266,6 +274,11 @@ export interface components { }; }; }; + Contact: { + content: { + "text/html": string; + }; + }; Error: { content: { "application/json": { @@ -339,6 +352,16 @@ export interface components { }; }; }; + Contact: { + content: { + "multipart/form-data": { + name: string; + email: string; + subject: string; + message: string; + }; + }; + }; PatchPost: { content: { "application/json": { diff --git a/packages/openapi-fetch/test/v1.yaml b/packages/openapi-fetch/test/v1.yaml index 2ce08de63..c22075742 100644 --- a/packages/openapi-fetch/test/v1.yaml +++ b/packages/openapi-fetch/test/v1.yaml @@ -208,7 +208,13 @@ paths: $ref: '#/components/responses/Error' 500: $ref: '#/components/responses/Error' - + /contact: + put: + requestBody: + $ref: '#/components/requestBodies/Contact' + responses: + 200: + $ref: '#/components/responses/Contact' components: schemas: Post: @@ -295,6 +301,26 @@ components: required: - message - replied_at + Contact: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + name: + type: string + email: + type: string + subject: + type: string + message: + type: string + required: + - name + - email + - subject + - message PatchPost: required: true content: @@ -347,6 +373,11 @@ components: type: string required: - message + Contact: + content: + text/html: + schema: + type: string Error: content: application/json: