From 76991b6e13525a4b22c2234e3111186dbcafd5e5 Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Sun, 8 Oct 2023 11:50:34 -0600 Subject: [PATCH 1/2] Fix openapi-fetch types --- packages/openapi-fetch/package.json | 2 +- packages/openapi-fetch/src/index.ts | 15 +- .../openapi-fetch/{src => test}/index.test.ts | 55 +- packages/openapi-fetch/test/v1.d.ts | 1175 ++++++----------- packages/openapi-fetch/test/v7-beta.d.ts | 782 +++++++++++ packages/openapi-fetch/test/v7-beta.test.ts | 885 +++++++++++++ .../openapi-typescript-helpers/index.d.ts | 2 +- pnpm-lock.yaml | 18 +- 8 files changed, 2152 insertions(+), 782 deletions(-) rename packages/openapi-fetch/{src => test}/index.test.ts (93%) create mode 100644 packages/openapi-fetch/test/v7-beta.d.ts create mode 100644 packages/openapi-fetch/test/v7-beta.test.ts diff --git a/packages/openapi-fetch/package.json b/packages/openapi-fetch/package.json index 399f587c8..c3989394a 100644 --- a/packages/openapi-fetch/package.json +++ b/packages/openapi-fetch/package.json @@ -70,7 +70,7 @@ "del-cli": "^5.1.0", "esbuild": "^0.19.4", "nanostores": "^0.9.3", - "openapi-typescript": "workspace:^", + "openapi-typescript": "^6.7.0", "openapi-typescript-codegen": "^0.25.0", "openapi-typescript-fetch": "^1.1.3", "superagent": "^8.1.2", diff --git a/packages/openapi-fetch/src/index.ts b/packages/openapi-fetch/src/index.ts index 51b04f1e3..7bc3dc24b 100644 --- a/packages/openapi-fetch/src/index.ts +++ b/packages/openapi-fetch/src/index.ts @@ -51,18 +51,13 @@ export interface DefaultParamsOption { params?: { query?: Record }; } -export interface EmptyParameters { - query?: never; - header?: never; - path?: never; - cookie?: never; -} - export type ParamsOption = T extends { parameters: any } ? HasRequiredKeys extends never ? { params?: T["parameters"] } : { params: T["parameters"] } - : never; + : DefaultParamsOption; +// v7 breaking change: TODO uncomment for openapi-typescript@7 support +// : never; export type RequestBodyOption = OperationRequestBodyContent extends never ? { body?: never } @@ -70,8 +65,7 @@ export type RequestBodyOption = OperationRequestBodyContent extends never ? { body?: OperationRequestBodyContent } : { body: OperationRequestBodyContent }; -export type FetchOptions = RequestOptions & - Omit & { fetch?: ClientOptions["fetch"] }; +export type FetchOptions = RequestOptions & Omit; export type FetchResponse = | { @@ -90,6 +84,7 @@ export type RequestOptions = ParamsOption & querySerializer?: QuerySerializer; bodySerializer?: BodySerializer; parseAs?: ParseAs; + fetch?: ClientOptions["fetch"]; }; export default function createClient( diff --git a/packages/openapi-fetch/src/index.test.ts b/packages/openapi-fetch/test/index.test.ts similarity index 93% rename from packages/openapi-fetch/src/index.test.ts rename to packages/openapi-fetch/test/index.test.ts index 1e68ac651..d6792c2c8 100644 --- a/packages/openapi-fetch/src/index.test.ts +++ b/packages/openapi-fetch/test/index.test.ts @@ -2,8 +2,8 @@ import { atom, computed } from "nanostores"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; // @ts-expect-error import createFetchMock from "vitest-fetch-mock"; -import type { paths } from "../test/v1.js"; -import createClient from "./index.js"; +import createClient from "../src/index.js"; +import type { paths } from "./v1.d.js"; const fetchMocker = createFetchMock(vi); @@ -395,7 +395,7 @@ describe("client", () => { expect(options?.headers).toEqual(new Headers()); }); - it("accepts a custom fetch function", async () => { + it("accepts a custom fetch function on createClient", async () => { function createCustomFetch(data: any) { const response = { clone: () => ({ ...response }), @@ -407,15 +407,48 @@ describe("client", () => { return async () => Promise.resolve(response); } - const baseData = { works: true }; - const customBaseFetch = createCustomFetch(baseData); - const client = createClient({ fetch: customBaseFetch }); - expect((await client.GET("/self")).data).toBe(baseData); + const customFetch = createCustomFetch({ works: true }); + mockFetchOnce({ status: 200, body: "{}" }); + + const client = createClient({ fetch: customFetch }); + const { data } = await client.GET("/self"); + + // assert data was returned from custom fetcher + expect(data).toEqual({ works: true }); + + // assert global fetch was never called + expect(fetchMocker).not.toHaveBeenCalled(); + }); + + it("accepts a custom fetch function per-request", async () => { + function createCustomFetch(data: any) { + const response = { + clone: () => ({ ...response }), + headers: new Headers(), + json: async () => data, + status: 200, + ok: true, + } as Response; + return async () => Promise.resolve(response); + } + + const fallbackFetch = createCustomFetch({ fetcher: "fallback" }); + const overrideFetch = createCustomFetch({ fetcher: "override" }); + + mockFetchOnce({ status: 200, body: "{}" }); + + const client = createClient({ fetch: fallbackFetch }); + + // assert override function was called + const fetch1 = await client.GET("/self", { fetch: overrideFetch }); + expect(fetch1.data).toEqual({ fetcher: "override" }); + + // assert fallback function still persisted (and wasn’t overridden) + const fetch2 = await client.GET("/self"); + expect(fetch2.data).toEqual({ fetcher: "fallback" }); - const data = { result: "it's working" }; - const customFetch = createCustomFetch(data); - const customResponse = await client.GET("/self", { fetch: customFetch }); - expect(customResponse.data).toBe(data); + // assert global fetch was never called + expect(fetchMocker).not.toHaveBeenCalled(); }); }); diff --git a/packages/openapi-fetch/test/v1.d.ts b/packages/openapi-fetch/test/v1.d.ts index 41a44bd7e..d62040b4a 100644 --- a/packages/openapi-fetch/test/v1.d.ts +++ b/packages/openapi-fetch/test/v1.d.ts @@ -3,780 +3,445 @@ * Do not make direct changes to the file. */ + export interface paths { - "/comment": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: components["requestBodies"]["CreateReply"]; - responses: { - 201: components["responses"]["CreateReply"]; - 500: components["responses"]["Error"]; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/blogposts": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: { - tags?: string[]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["AllPostsGet"]; - 500: components["responses"]["Error"]; - }; - }; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: components["requestBodies"]["CreatePost"]; - responses: { - 201: components["responses"]["CreatePost"]; - 500: components["responses"]["Error"]; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: components["requestBodies"]["PatchPost"]; - responses: { - 201: components["responses"]["PatchPost"]; - }; - }; - trace?: never; - }; - "/blogposts/{post_id}": { - parameters: { - query?: never; - header?: never; - path: { - post_id: string; - }; - cookie?: never; - }; - get: { - parameters: { - query?: { - version?: number; - format?: string; - }; - header?: never; - path: { - post_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["PostGet"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - put?: never; - post?: never; - delete: { - parameters: { - query?: never; - header?: never; - path: { - post_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["PostDelete"]; - 500: components["responses"]["Error"]; - }; - }; - options?: never; - head?: never; - patch: { - parameters: { - query?: never; - header?: never; - path: { - post_id: string; - }; - cookie?: never; - }; - requestBody: components["requestBodies"]["PatchPost"]; - responses: { - 200: components["responses"]["PatchPost"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - trace?: never; - }; - "/blogposts-optional": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: components["requestBodies"]["CreatePostOptional"]; - responses: { - 201: components["responses"]["CreatePost"]; - 500: components["responses"]["Error"]; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/blogposts-optional-inline": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: { - content: { - "application/json": components["schemas"]["Post"]; - }; - }; - responses: { - 201: components["responses"]["CreatePost"]; - 500: components["responses"]["Error"]; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/header-params": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getHeaderParams"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/media": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": { - /** Format: blob */ - media: string; - name: string; - }; - }; - }; - responses: { - "2XX": { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - status: string; - }; - }; - }; - "4XX": components["responses"]["Error"]; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/self": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/string-array": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["StringArray"]; - 500: components["responses"]["Error"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/tag/{name}": { - parameters: { - query?: never; - header?: never; - path: { - name: string; - }; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path: { - name: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["Tag"]; - 500: components["responses"]["Error"]; - }; - }; - put: { - parameters: { - query?: never; - header?: never; - path: { - name: string; - }; - cookie?: never; - }; - requestBody: components["requestBodies"]["CreateTag"]; - responses: { - 201: components["responses"]["CreateTag"]; - 500: components["responses"]["Error"]; - }; - }; - post?: never; - delete: { - parameters: { - query?: never; - header?: never; - path: { - name: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description No Content */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 500: components["responses"]["Error"]; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/default-as-error": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - default: components["responses"]["Error"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/anyMethod": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - delete: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - options: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - head: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - patch: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; - trace: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: components["responses"]["User"]; - 404: components["responses"]["Error"]; - 500: components["responses"]["Error"]; - }; - }; + "/comment": { + put: { + requestBody: components["requestBodies"]["CreateReply"]; + responses: { + 201: components["responses"]["CreateReply"]; + 500: components["responses"]["Error"]; + }; }; - "/contact": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: components["requestBodies"]["Contact"]; - responses: { - 200: components["responses"]["Contact"]; - }; - }; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; + }; + "/blogposts": { + get: { + parameters: { + query?: { + tags?: string[]; + }; + }; + responses: { + 200: components["responses"]["AllPostsGet"]; + 500: components["responses"]["Error"]; + }; + }; + put: { + requestBody: components["requestBodies"]["CreatePost"]; + responses: { + 201: components["responses"]["CreatePost"]; + 500: components["responses"]["Error"]; + }; + }; + patch: { + requestBody: components["requestBodies"]["PatchPost"]; + responses: { + 201: components["responses"]["PatchPost"]; + }; }; + }; + "/blogposts/{post_id}": { + get: { + parameters: { + query?: { + version?: number; + format?: string; + }; + path: { + post_id: string; + }; + }; + responses: { + 200: components["responses"]["PostGet"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + delete: { + parameters: { + path: { + post_id: string; + }; + }; + responses: { + 200: components["responses"]["PostDelete"]; + 500: components["responses"]["Error"]; + }; + }; + patch: { + parameters: { + path: { + post_id: string; + }; + }; + requestBody: components["requestBodies"]["PatchPost"]; + responses: { + 200: components["responses"]["PatchPost"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + parameters: { + path: { + post_id: string; + }; + }; + }; + "/blogposts-optional": { + put: { + requestBody: components["requestBodies"]["CreatePostOptional"]; + responses: { + 201: components["responses"]["CreatePost"]; + 500: components["responses"]["Error"]; + }; + }; + }; + "/blogposts-optional-inline": { + put: { + requestBody?: { + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + responses: { + 201: components["responses"]["CreatePost"]; + 500: components["responses"]["Error"]; + }; + }; + }; + "/header-params": { + get: operations["getHeaderParams"]; + }; + "/media": { + put: { + requestBody: { + content: { + "application/json": { + /** Format: blob */ + media: string; + name: string; + }; + }; + }; + responses: { + "2XX": { + content: { + "application/json": { + status: string; + }; + }; + }; + "4XX": components["responses"]["Error"]; + }; + }; + }; + "/self": { + get: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + }; + "/string-array": { + get: { + responses: { + 200: components["responses"]["StringArray"]; + 500: components["responses"]["Error"]; + }; + }; + }; + "/tag/{name}": { + get: { + parameters: { + path: { + name: string; + }; + }; + responses: { + 200: components["responses"]["Tag"]; + 500: components["responses"]["Error"]; + }; + }; + put: { + parameters: { + path: { + name: string; + }; + }; + requestBody: components["requestBodies"]["CreateTag"]; + responses: { + 201: components["responses"]["CreateTag"]; + 500: components["responses"]["Error"]; + }; + }; + delete: { + parameters: { + path: { + name: string; + }; + }; + responses: { + /** @description No Content */ + 204: { + content: never; + }; + 500: components["responses"]["Error"]; + }; + }; + parameters: { + path: { + name: string; + }; + }; + }; + "/default-as-error": { + get: { + responses: { + default: components["responses"]["Error"]; + }; + }; + }; + "/anyMethod": { + get: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + put: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + post: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + delete: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + options: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + head: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + patch: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + trace: { + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + }; + "/contact": { + put: { + requestBody: components["requestBodies"]["Contact"]; + responses: { + 200: components["responses"]["Contact"]; + }; + }; + }; } + export type webhooks = Record; + export interface components { - schemas: { - Post: { - title: string; - body: string; - publish_date?: number; - }; - StringArray: string[]; - User: { - email: string; - age?: number; - avatar?: string; - }; + schemas: { + Post: { + title: string; + body: string; + publish_date?: number; }; - responses: { - AllPostsGet: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Post"][]; - }; - }; - CreatePost: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - status: string; - }; - }; - }; - CreateTag: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - status: string; - }; - }; - }; - CreateReply: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json;charset=utf-8": { - message: string; - }; - }; - }; - Contact: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - Error: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - code: number; - message: string; - }; - }; - }; - PatchPost: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - status: string; - }; - }; - }; - PostDelete: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - status: string; - }; - }; - }; - PostGet: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Post"]; - }; - }; - StringArray: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["StringArray"]; - }; - }; - Tag: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": string; - }; + StringArray: string[]; + User: { + email: string; + age?: number; + avatar?: string; + }; + }; + responses: { + AllPostsGet: { + content: { + "application/json": components["schemas"]["Post"][]; + }; + }; + CreatePost: { + content: { + "application/json": { + status: string; }; - User: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["User"]; - }; + }; + }; + CreateTag: { + content: { + "application/json": { + status: string; }; + }; }; - parameters: never; - requestBodies: { - CreatePost: { - content: { - "application/json": { - title: string; - body: string; - publish_date: number; - }; - }; + CreateReply: { + content: { + "application/json;charset=utf-8": { + message: string; }; - CreatePostOptional: { - content: { - "application/json": { - title: string; - body: string; - publish_date: number; - }; - }; + }; + }; + Contact: { + content: { + "text/html": string; + }; + }; + Error: { + content: { + "application/json": { + code: number; + message: string; }; - CreateTag: { - content: { - "application/json": { - description?: string; - }; - }; + }; + }; + PatchPost: { + content: { + "application/json": { + status: string; }; - CreateReply: { - content: { - "application/json;charset=utf-8": { - message: string; - replied_at: number; - }; - }; + }; + }; + PostDelete: { + content: { + "application/json": { + status: string; }; - Contact: { - content: { - "multipart/form-data": { - name: string; - email: string; - subject: string; - message: string; - }; - }; + }; + }; + PostGet: { + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + StringArray: { + content: { + "application/json": components["schemas"]["StringArray"]; + }; + }; + Tag: { + content: { + "application/json": string; + }; + }; + User: { + content: { + "application/json": components["schemas"]["User"]; + }; + }; + }; + parameters: never; + requestBodies: { + CreatePost: { + content: { + "application/json": { + title: string; + body: string; + publish_date: number; + }; + }; + }; + CreatePostOptional?: { + content: { + "application/json": { + title: string; + body: string; + publish_date: number; + }; + }; + }; + CreateTag: { + content: { + "application/json": { + description?: string; }; - PatchPost: { - content: { - "application/json": { - title?: string; - body?: string; - publish_date?: number; - }; - }; + }; + }; + CreateReply: { + content: { + "application/json;charset=utf-8": { + message: string; + replied_at: number; }; + }; + }; + Contact: { + content: { + "multipart/form-data": { + name: string; + email: string; + subject: string; + message: string; + }; + }; }; - headers: never; - pathItems: never; + PatchPost: { + content: { + "application/json": { + title?: string; + body?: string; + publish_date?: number; + }; + }; + }; + }; + headers: never; + pathItems: never; } + export type $defs = Record; + +export type external = Record; + export interface operations { - getHeaderParams: { - parameters: { - query?: never; - header: { - "x-required-header": string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - status: string; - }; - }; - }; - 500: components["responses"]["Error"]; - }; + + getHeaderParams: { + parameters: { + header: { + "x-required-header": string; + }; + }; + responses: { + 200: { + content: { + "application/json": { + status: string; + }; + }; + }; + 500: components["responses"]["Error"]; }; + }; } diff --git a/packages/openapi-fetch/test/v7-beta.d.ts b/packages/openapi-fetch/test/v7-beta.d.ts new file mode 100644 index 000000000..41a44bd7e --- /dev/null +++ b/packages/openapi-fetch/test/v7-beta.d.ts @@ -0,0 +1,782 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/comment": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: components["requestBodies"]["CreateReply"]; + responses: { + 201: components["responses"]["CreateReply"]; + 500: components["responses"]["Error"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/blogposts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + tags?: string[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["AllPostsGet"]; + 500: components["responses"]["Error"]; + }; + }; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: components["requestBodies"]["CreatePost"]; + responses: { + 201: components["responses"]["CreatePost"]; + 500: components["responses"]["Error"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: components["requestBodies"]["PatchPost"]; + responses: { + 201: components["responses"]["PatchPost"]; + }; + }; + trace?: never; + }; + "/blogposts/{post_id}": { + parameters: { + query?: never; + header?: never; + path: { + post_id: string; + }; + cookie?: never; + }; + get: { + parameters: { + query?: { + version?: number; + format?: string; + }; + header?: never; + path: { + post_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["PostGet"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + put?: never; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + post_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["PostDelete"]; + 500: components["responses"]["Error"]; + }; + }; + options?: never; + head?: never; + patch: { + parameters: { + query?: never; + header?: never; + path: { + post_id: string; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["PatchPost"]; + responses: { + 200: components["responses"]["PatchPost"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + trace?: never; + }; + "/blogposts-optional": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: components["requestBodies"]["CreatePostOptional"]; + responses: { + 201: components["responses"]["CreatePost"]; + 500: components["responses"]["Error"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/blogposts-optional-inline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + responses: { + 201: components["responses"]["CreatePost"]; + 500: components["responses"]["Error"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/header-params": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getHeaderParams"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/media": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: blob */ + media: string; + name: string; + }; + }; + }; + responses: { + "2XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + }; + }; + }; + "4XX": components["responses"]["Error"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/self": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/string-array": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["StringArray"]; + 500: components["responses"]["Error"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/{name}": { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Tag"]; + 500: components["responses"]["Error"]; + }; + }; + put: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: components["requestBodies"]["CreateTag"]; + responses: { + 201: components["responses"]["CreateTag"]; + 500: components["responses"]["Error"]; + }; + }; + post?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["Error"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/default-as-error": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + default: components["responses"]["Error"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/anyMethod": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + options: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + head: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + trace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["User"]; + 404: components["responses"]["Error"]; + 500: components["responses"]["Error"]; + }; + }; + }; + "/contact": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: components["requestBodies"]["Contact"]; + responses: { + 200: components["responses"]["Contact"]; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Post: { + title: string; + body: string; + publish_date?: number; + }; + StringArray: string[]; + User: { + email: string; + age?: number; + avatar?: string; + }; + }; + responses: { + AllPostsGet: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"][]; + }; + }; + CreatePost: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + }; + }; + }; + CreateTag: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + }; + }; + }; + CreateReply: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=utf-8": { + message: string; + }; + }; + }; + Contact: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + Error: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + code: number; + message: string; + }; + }; + }; + PatchPost: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + }; + }; + }; + PostDelete: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + }; + }; + }; + PostGet: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Post"]; + }; + }; + StringArray: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StringArray"]; + }; + }; + Tag: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + User: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["User"]; + }; + }; + }; + parameters: never; + requestBodies: { + CreatePost: { + content: { + "application/json": { + title: string; + body: string; + publish_date: number; + }; + }; + }; + CreatePostOptional: { + content: { + "application/json": { + title: string; + body: string; + publish_date: number; + }; + }; + }; + CreateTag: { + content: { + "application/json": { + description?: string; + }; + }; + }; + CreateReply: { + content: { + "application/json;charset=utf-8": { + message: string; + replied_at: number; + }; + }; + }; + Contact: { + content: { + "multipart/form-data": { + name: string; + email: string; + subject: string; + message: string; + }; + }; + }; + PatchPost: { + content: { + "application/json": { + title?: string; + body?: string; + publish_date?: number; + }; + }; + }; + }; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHeaderParams: { + parameters: { + query?: never; + header: { + "x-required-header": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + status: string; + }; + }; + }; + 500: components["responses"]["Error"]; + }; + }; +} diff --git a/packages/openapi-fetch/test/v7-beta.test.ts b/packages/openapi-fetch/test/v7-beta.test.ts new file mode 100644 index 000000000..2e3a64524 --- /dev/null +++ b/packages/openapi-fetch/test/v7-beta.test.ts @@ -0,0 +1,885 @@ +import { atom, computed } from "nanostores"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +// @ts-expect-error +import createFetchMock from "vitest-fetch-mock"; +import createClient from "../src/index.js"; +import type { paths } from "./v7-beta.js"; + +// Note +// This is a copy of index.test.ts, but uses generated types from openapi-typescript@7 (beta). +// This tests upcoming compatibility until openapi-typescript@7 is stable and the two tests +// merged together. + +const fetchMocker = createFetchMock(vi); + +beforeAll(() => { + fetchMocker.enableMocks(); +}); +afterEach(() => { + fetchMocker.resetMocks(); +}); + +interface MockResponse { + headers?: Record; + status: number; + body: any; +} + +function mockFetch(res: MockResponse) { + fetchMocker.mockResponse(() => res); +} + +function mockFetchOnce(res: MockResponse) { + fetchMocker.mockResponseOnce(() => res); +} + +describe("client", () => { + it("generates all proper functions", () => { + const client = createClient(); + + expect(client).toHaveProperty("GET"); + expect(client).toHaveProperty("PUT"); + expect(client).toHaveProperty("POST"); + expect(client).toHaveProperty("DELETE"); + expect(client).toHaveProperty("OPTIONS"); + expect(client).toHaveProperty("HEAD"); + expect(client).toHaveProperty("PATCH"); + expect(client).toHaveProperty("TRACE"); + }); + + describe("TypeScript checks", () => { + it("marks data or error as undefined, but never both", async () => { + const client = createClient(); + + // data + mockFetchOnce({ + status: 200, + body: JSON.stringify(["one", "two", "three"]), + }); + const dataRes = await client.GET("/string-array"); + + // … is initially possibly undefined + // @ts-expect-error + expect(dataRes.data[0]).toBe("one"); + + // … is present if error is undefined + if (!dataRes.error) { + expect(dataRes.data[0]).toBe("one"); + } + + // … means data is undefined + if (dataRes.data) { + // @ts-expect-error + expect(() => dataRes.error.message).toThrow(); + } + + // error + mockFetchOnce({ + status: 500, + body: JSON.stringify({ code: 500, message: "Something went wrong" }), + }); + const errorRes = await client.GET("/string-array"); + + // … is initially possibly undefined + // @ts-expect-error + expect(errorRes.error.message).toBe("Something went wrong"); + + // … is present if error is undefined + if (!errorRes.data) { + expect(errorRes.error.message).toBe("Something went wrong"); + } + + // … means data is undefined + if (errorRes.error) { + // @ts-expect-error + expect(() => errorRes.data[0]).toThrow(); + } + }); + + describe("params", () => { + it("path", async () => { + const client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); + + // expect error on missing 'params' + await client.GET("/blogposts/{post_id}", { + // @ts-expect-error + TODO: "this should be an error", + }); + + // expect error on empty params + await client.GET("/blogposts/{post_id}", { + // @ts-expect-error + params: { TODO: "this should be an error" }, + }); + + // expect error on empty params.path + // @ts-expect-error + await client.GET("/blogposts/{post_id}", { params: { path: {} } }); + + // expect error on mismatched type (number v string) + await client.GET("/blogposts/{post_id}", { + // @ts-expect-error + params: { path: { post_id: 1234 } }, + }); + + // (no error) + await client.GET("/blogposts/{post_id}", { + params: { path: { post_id: "1234" } }, + }); + + // expect param passed correctly + const lastCall = + fetchMocker.mock.calls[fetchMocker.mock.calls.length - 1]; + expect(lastCall[0]).toBe("https://myapi.com/v1/blogposts/1234"); + }); + + it("header", async () => { + const client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ status: "success" }) }); + + // expet error on missing header + // @ts-expect-error + await client.GET("/header-params", { TODO: "this should be an error" }); + + // expect error on incorrect header + await client.GET("/header-params", { + // @ts-expect-error + params: { header: { foo: "bar" } }, + }); + + // expect error on mismatched type + await client.GET("/header-params", { + // @ts-expect-error + params: { header: { "x-required-header": true } }, + }); + + // (no error) + await client.GET("/header-params", { + params: { header: { "x-required-header": "correct" } }, + }); + + // expect param passed correctly + const lastCall = + fetchMocker.mock.calls[fetchMocker.mock.calls.length - 1]; + expect(lastCall[1].headers.get("x-required-header")).toBe("correct"); + }); + + describe("query", () => { + it("basic", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: 2, format: "json" }, + }, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe( + "/blogposts/my-post?version=2&format=json", + ); + }); + + it("array params", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts", { + params: { + query: { tags: ["one", "two", "three"] }, + }, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe( + "/blogposts?tags=one%2Ctwo%2Cthree", + ); + }); + + it("empty/null params", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: undefined, format: null as any }, + }, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/my-post"); + }); + + describe("querySerializer", () => { + it("custom", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: 2, format: "json" }, + }, + querySerializer: (q) => `alpha=${q.version}&beta=${q.format}`, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe( + "/blogposts/my-post?alpha=2&beta=json", + ); + }); + + it("applies global serializer", async () => { + const client = createClient({ + querySerializer: (q) => `alpha=${q.version}&beta=${q.format}`, + }); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: 2, format: "json" }, + }, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe( + "/blogposts/my-post?alpha=2&beta=json", + ); + }); + + it("overrides global serializer if provided", async () => { + const client = createClient({ + querySerializer: () => "query", + }); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + params: { + path: { post_id: "my-post" }, + query: { version: 2, format: "json" }, + }, + querySerializer: (q) => `alpha=${q.version}&beta=${q.format}`, + }); + + expect(fetchMocker.mock.calls[0][0]).toBe( + "/blogposts/my-post?alpha=2&beta=json", + ); + }); + }); + }); + }); + + describe("body", () => { + // these are pure type tests; no runtime assertions needed + /* eslint-disable vitest/expect-expect */ + it("requires necessary requestBodies", async () => { + const client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); + + // expect error on missing `body` + // @ts-expect-error + await client.PUT("/blogposts"); + + // expect error on missing fields + // @ts-expect-error + await client.PUT("/blogposts", { body: { title: "Foo" } }); + + // expect present body to be good enough (all fields optional) + // (no error) + await client.PUT("/blogposts", { + body: { + title: "Foo", + body: "Bar", + publish_date: new Date("2023-04-01T12:00:00Z").getTime(), + }, + }); + }); + + it("requestBody (inline)", async () => { + mockFetch({ status: 201, body: "{}" }); + const client = createClient(); + + // expect error on wrong body type + await client.PUT("/blogposts-optional-inline", { + // @ts-expect-error + body: { error: true }, + }); + + // (no error) + await client.PUT("/blogposts-optional-inline", { + body: { + title: "", + publish_date: 3, + body: "", + }, + }); + }); + + it("requestBody with required: false", async () => { + mockFetch({ status: 201, body: "{}" }); + const client = createClient(); + + // assert missing `body` doesn’t raise a TS error + await client.PUT("/blogposts-optional"); + + // assert error on type mismatch + // @ts-expect-error + await client.PUT("/blogposts-optional", { body: { error: true } }); + + // (no error) + await client.PUT("/blogposts-optional", { + body: { + title: "", + publish_date: 3, + body: "", + }, + }); + }); + }); + /* eslint-enable vitest/expect-expect */ + }); + + describe("options", () => { + it("respects baseUrl", async () => { + let client = createClient({ baseUrl: "https://myapi.com/v1" }); + mockFetch({ status: 200, body: JSON.stringify({ message: "OK" }) }); + await client.GET("/self"); + + // assert baseUrl and path mesh as expected + expect(fetchMocker.mock.calls[0][0]).toBe("https://myapi.com/v1/self"); + + client = createClient({ baseUrl: "https://myapi.com/v1/" }); + await client.GET("/self"); + // assert trailing '/' was removed + expect(fetchMocker.mock.calls[1][0]).toBe("https://myapi.com/v1/self"); + }); + + it("preserves default headers", async () => { + const headers: HeadersInit = { Authorization: "Bearer secrettoken" }; + + const client = createClient({ headers }); + mockFetchOnce({ + status: 200, + body: JSON.stringify({ email: "user@user.com" }), + }); + await client.GET("/self"); + + // assert default headers were passed + const options = fetchMocker.mock.calls[0][1]; + expect(options?.headers).toEqual( + new Headers({ + ...headers, // assert new header got passed + "Content-Type": "application/json", // probably doesn’t need to get tested, but this was simpler than writing lots of code to ignore these + }), + ); + }); + + it("allows override headers", async () => { + const client = createClient({ + headers: { "Cache-Control": "max-age=10000000" }, + }); + mockFetchOnce({ + status: 200, + body: JSON.stringify({ email: "user@user.com" }), + }); + await client.GET("/self", { + params: {}, + headers: { "Cache-Control": "no-cache" }, + }); + + // assert default headers were passed + const options = fetchMocker.mock.calls[0][1]; + expect(options?.headers).toEqual( + new Headers({ + "Cache-Control": "no-cache", + "Content-Type": "application/json", + }), + ); + }); + + it("allows unsetting headers", async () => { + const client = createClient({ headers: { "Content-Type": null } }); + mockFetchOnce({ + status: 200, + body: JSON.stringify({ email: "user@user.com" }), + }); + await client.GET("/self", { params: {} }); + + // assert default headers were passed + const options = fetchMocker.mock.calls[0][1]; + expect(options?.headers).toEqual(new Headers()); + }); + + it("accepts a custom fetch function on createClient", async () => { + function createCustomFetch(data: any) { + const response = { + clone: () => ({ ...response }), + headers: new Headers(), + json: async () => data, + status: 200, + ok: true, + } as Response; + return async () => Promise.resolve(response); + } + + const customFetch = createCustomFetch({ works: true }); + mockFetchOnce({ status: 200, body: "{}" }); + + const client = createClient({ fetch: customFetch }); + const { data } = await client.GET("/self"); + + // assert data was returned from custom fetcher + expect(data).toEqual({ works: true }); + + // assert global fetch was never called + expect(fetchMocker).not.toHaveBeenCalled(); + }); + + it("accepts a custom fetch function per-request", async () => { + function createCustomFetch(data: any) { + const response = { + clone: () => ({ ...response }), + headers: new Headers(), + json: async () => data, + status: 200, + ok: true, + } as Response; + return async () => Promise.resolve(response); + } + + const fallbackFetch = createCustomFetch({ fetcher: "fallback" }); + const overrideFetch = createCustomFetch({ fetcher: "override" }); + + mockFetchOnce({ status: 200, body: "{}" }); + + const client = createClient({ fetch: fallbackFetch }); + + // assert override function was called + const fetch1 = await client.GET("/self", { fetch: overrideFetch }); + expect(fetch1.data).toEqual({ fetcher: "override" }); + + // assert fallback function still persisted (and wasn’t overridden) + const fetch2 = await client.GET("/self"); + expect(fetch2.data).toEqual({ fetcher: "fallback" }); + + // assert global fetch was never called + expect(fetchMocker).not.toHaveBeenCalled(); + }); + }); + + describe("requests", () => { + it("escapes URLs properly", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + 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: "{}" }); + await client.PUT("/contact", { + 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); + + // TODO: `vitest-fetch-mock` does not add the boundary to the Content-Type header like browsers do, so we expect the header to be null instead + expect((req.headers as Headers).get("Content-Type")).toBeNull(); + }); + }); + + describe("responses", () => { + it("returns empty object on 204", async () => { + const client = createClient(); + mockFetchOnce({ status: 204, body: "" }); + const { data, error, response } = await client.DELETE("/tag/{name}", { + params: { path: { name: "New Tag" } }, + }); + + // assert correct data was returned + expect(data).toEqual({}); + expect(response.status).toBe(204); + + // assert error is empty + expect(error).toBeUndefined(); + }); + + it("treats `default` as an error", async () => { + const client = createClient({ + headers: { "Cache-Control": "max-age=10000000" }, + }); + mockFetchOnce({ + status: 500, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: 500, + message: "An unexpected error occurred", + }), + }); + const { error } = await client.GET("/default-as-error"); + + // discard `data` object + if (!error) { + throw new Error( + "treats `default` as an error: error response should be present", + ); + } + + // assert `error.message` doesn’t throw TS error + expect(error.message).toBe("An unexpected error occurred"); + }); + + describe("parseAs", () => { + it("text", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.GET("/anyMethod", { parseAs: "text" }); + expect(data).toBe("{}"); + }); + + it("arrayBuffer", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.GET("/anyMethod", { + parseAs: "arrayBuffer", + }); + expect(data instanceof ArrayBuffer).toBe(true); + }); + + it("blob", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.GET("/anyMethod", { parseAs: "blob" }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((data as any).constructor.name).toBe("Blob"); + }); + + it("stream", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + const { data } = await client.GET("/anyMethod", { parseAs: "stream" }); + expect(data instanceof Buffer).toBe(true); + }); + }); + }); + + describe("GET()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); + }); + + it("sends correct options, returns success", async () => { + const mockData = { + title: "My Post", + body: "

This is a very good post

", + publish_date: new Date("2023-03-01T12:00:00Z").getTime(), + }; + const client = createClient(); + mockFetchOnce({ status: 200, body: JSON.stringify(mockData) }); + const { data, error, response } = await client.GET( + "/blogposts/{post_id}", + { + params: { path: { post_id: "my-post" } }, + }, + ); + + // assert correct URL was called + expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/my-post"); + + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(200); + + // assert error is empty + expect(error).toBeUndefined(); + }); + + it("sends correct options, returns error", async () => { + const mockError = { code: 404, message: "Post not found" }; + const client = createClient(); + mockFetchOnce({ status: 404, body: JSON.stringify(mockError) }); + const { data, error, response } = await client.GET( + "/blogposts/{post_id}", + { + params: { path: { post_id: "my-post" } }, + }, + ); + + // assert correct URL was called + expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts/my-post"); + + // assert correct method was called + expect(fetchMocker.mock.calls[0][1]?.method).toBe("GET"); + + // assert correct error was returned + expect(error).toEqual(mockError); + expect(response.status).toBe(404); + + // assert data is empty + expect(data).toBeUndefined(); + }); + + // note: this was a previous bug in the type inference + it("handles array-type responses", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "[]" }); + const { data } = await client.GET("/blogposts", { params: {} }); + if (!data) { + throw new Error("data empty"); + } + + // assert array type (and only array type) was inferred + expect(data.length).toBe(0); + }); + + it("handles literal 2XX and 4XX codes", async () => { + const client = createClient(); + mockFetch({ status: 201, body: '{"status": "success"}' }); + const { data, error } = await client.PUT("/media", { + body: { media: "base64", name: "myImage" }, + }); + + if (data) { + // assert 2XX type inferred correctly + expect(data.status).toBe("success"); + } else { + // assert 4XX type inferred correctly + // (this should be a dead code path but tests TS types) + expect(error.message).toBe("Error"); + } + }); + }); + + describe("POST()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.POST("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("POST"); + }); + + it("sends correct options, returns success", async () => { + const mockData = { status: "success" }; + const client = createClient(); + mockFetchOnce({ status: 201, body: JSON.stringify(mockData) }); + const { data, error, response } = await client.PUT("/blogposts", { + body: { + title: "New Post", + body: "

Best post yet

", + publish_date: new Date("2023-03-31T12:00:00Z").getTime(), + }, + }); + + // assert correct URL was called + expect(fetchMocker.mock.calls[0][0]).toBe("/blogposts"); + + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(201); + + // assert error is empty + expect(error).toBeUndefined(); + }); + + it("supports sepecifying utf-8 encoding", async () => { + const mockData = { message: "My reply" }; + const client = createClient(); + mockFetchOnce({ status: 201, body: JSON.stringify(mockData) }); + const { data, error, response } = await client.PUT("/comment", { + params: {}, + body: { + message: "My reply", + replied_at: new Date("2023-03-31T12:00:00Z").getTime(), + }, + }); + + // assert correct data was returned + expect(data).toEqual(mockData); + expect(response.status).toBe(201); + + // assert error is empty + expect(error).toBeUndefined(); + }); + }); + + describe("DELETE()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.DELETE("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("DELETE"); + }); + + it("returns empty object on 204", async () => { + const client = createClient(); + mockFetchOnce({ status: 204, body: "" }); + const { data, error } = await client.DELETE("/blogposts/{post_id}", { + params: { + path: { post_id: "123" }, + }, + }); + + // assert correct data was returned + expect(data).toEqual({}); + + // assert error is empty + expect(error).toBeUndefined(); + }); + + it("returns empty object on Content-Length: 0", async () => { + const client = createClient(); + mockFetchOnce({ + headers: { "Content-Length": "0" }, + status: 200, + body: "", + }); + const { data, error } = await client.DELETE("/blogposts/{post_id}", { + params: { + path: { post_id: "123" }, + }, + }); + + // assert correct data was returned + expect(data).toEqual({}); + + // assert error is empty + expect(error).toBeUndefined(); + }); + }); + + describe("OPTIONS()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.OPTIONS("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("OPTIONS"); + }); + }); + + describe("HEAD()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.HEAD("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("HEAD"); + }); + }); + + describe("PATCH()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.PATCH("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("PATCH"); + }); + }); + + describe("TRACE()", () => { + it("sends the correct method", async () => { + const client = createClient(); + mockFetchOnce({ status: 200, body: "{}" }); + await client.TRACE("/anyMethod"); + expect(fetchMocker.mock.calls[0][1]?.method).toBe("TRACE"); + }); + }); +}); + +// test that the library behaves as expected inside commonly-used patterns +describe("examples", () => { + it("nanostores", async () => { + const token = atom(); + const client = computed([token], (currentToken) => + createClient({ + headers: currentToken + ? { Authorization: `Bearer ${currentToken}` } + : {}, + }), + ); + + // assert initial call is unauthenticated + mockFetchOnce({ status: 200, body: "{}" }); + await client + .get() + .GET("/blogposts/{post_id}", { params: { path: { post_id: "1234" } } }); + expect( + fetchMocker.mock.calls[0][1].headers.get("authorization"), + ).toBeNull(); + + // assert after setting token, client is authenticated + const tokenVal = "abcd"; + mockFetchOnce({ status: 200, body: "{}" }); + await new Promise((resolve) => + setTimeout(() => { + token.set(tokenVal); // simulate promise-like token setting + resolve(); + }, 0), + ); + await client + .get() + .GET("/blogposts/{post_id}", { params: { path: { post_id: "1234" } } }); + expect(fetchMocker.mock.calls[1][1].headers.get("authorization")).toBe( + `Bearer ${tokenVal}`, + ); + }); + + it("proxies", async () => { + let token: string | undefined = undefined; + + const baseClient = createClient(); + const client = new Proxy(baseClient, { + get(_, key: keyof typeof baseClient) { + const newClient = createClient({ + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + return newClient[key]; + }, + }); + + // assert initial call is unauthenticated + mockFetchOnce({ status: 200, body: "{}" }); + await client.GET("/blogposts/{post_id}", { + params: { path: { post_id: "1234" } }, + }); + expect( + fetchMocker.mock.calls[0][1].headers.get("authorization"), + ).toBeNull(); + + // assert after setting token, client is authenticated + const tokenVal = "abcd"; + mockFetchOnce({ status: 200, body: "{}" }); + await new Promise((resolve) => + setTimeout(() => { + token = tokenVal; // simulate promise-like token setting + resolve(); + }, 0), + ); + await client.GET("/blogposts/{post_id}", { + params: { path: { post_id: "1234" } }, + }); + expect(fetchMocker.mock.calls[1][1].headers.get("authorization")).toBe( + `Bearer ${tokenVal}`, + ); + }); +}); diff --git a/packages/openapi-typescript-helpers/index.d.ts b/packages/openapi-typescript-helpers/index.d.ts index 5e481d47c..b7f851f11 100644 --- a/packages/openapi-typescript-helpers/index.d.ts +++ b/packages/openapi-typescript-helpers/index.d.ts @@ -21,7 +21,7 @@ export type ErrorStatus = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | /** Given an OpenAPI **Paths Object**, find all paths that have the given method */ export type PathsWithMethod< - Paths extends Record, + Paths extends {}, PathnameMethod extends HttpMethod, > = { [Pathname in keyof Paths]: Paths[Pathname] extends { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc217ea81..c5ea93c48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,8 +131,8 @@ importers: specifier: ^0.9.3 version: 0.9.3 openapi-typescript: - specifier: workspace:^ - version: link:../openapi-typescript + specifier: ^6.7.0 + version: 6.7.0 openapi-typescript-codegen: specifier: ^0.25.0 version: 0.25.0 @@ -1516,7 +1516,6 @@ packages: /@fastify/busboy@2.0.0: resolution: {integrity: sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==} engines: {node: '>=14'} - dev: false /@humanwhocodes/config-array@0.11.11: resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==} @@ -6053,6 +6052,18 @@ packages: engines: {node: '>= 12.0.0', npm: '>= 7.0.0'} dev: true + /openapi-typescript@6.7.0: + resolution: {integrity: sha512-eoUfJwhnMEug7euZ1dATG7iRiDVsEROwdPkhLUDiaFjcClV4lzft9F0Ii0fYjULCPNIiWiFi0BqMpSxipuvAgQ==} + hasBin: true + dependencies: + ansi-colors: 4.1.3 + fast-glob: 3.3.1 + js-yaml: 4.1.0 + supports-color: 9.4.0 + undici: 5.25.4 + yargs-parser: 21.1.1 + dev: true + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -7783,7 +7794,6 @@ packages: engines: {node: '>=14.0'} dependencies: '@fastify/busboy': 2.0.0 - dev: false /unherit@3.0.1: resolution: {integrity: sha512-akOOQ/Yln8a2sgcLj4U0Jmx0R5jpIg2IUyRrWOzmEbjBtGzBdHtSeFKgoEcoH4KYIG/Pb8GQ/BwtYm0GCq1Sqg==} From b112cfe7e61d1fb720587959195063868d3e07fb Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Sun, 8 Oct 2023 11:52:18 -0600 Subject: [PATCH 2/2] =?UTF-8?q?Don=E2=80=99t=20publish=20examples=20to=20n?= =?UTF-8?q?pm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/openapi-fetch/.npmignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/openapi-fetch/.npmignore b/packages/openapi-fetch/.npmignore index 9daeafb98..2c7a90b40 100644 --- a/packages/openapi-fetch/.npmignore +++ b/packages/openapi-fetch/.npmignore @@ -1 +1,5 @@ +.eslintignore +.prettierignore +examples test +vitest.config.ts