From 04f9779ab20b83813c131f9c68a850bbf0a19478 Mon Sep 17 00:00:00 2001 From: niboshi Date: Sun, 20 Oct 2024 20:44:38 +0900 Subject: [PATCH] Res header (#126) * Add ImmutableHeaders * Test headers type inference * Add doc --- docs/docs/04_client.md | 29 +++++++++++++++++++++++++---- examples/fastify/zod/fetch.ts | 4 ++++ examples/spec/zod.ts | 4 ++-- src/core/headers.t-test.ts | 22 ++++++++++++++++++++++ src/core/headers.ts | 16 ++++++++++++++++ src/core/hono-types.ts | 7 +++++-- src/core/spec.ts | 9 ++++++++- src/core/type.t-test.ts | 22 ++++++++++++++++++++++ src/core/type.ts | 14 ++++++++++++++ src/fetch/index.t-test.ts | 12 +++++++++++- 10 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 src/core/headers.t-test.ts create mode 100644 src/core/headers.ts diff --git a/docs/docs/04_client.md b/docs/docs/04_client.md index ec109d0..eee5b51 100644 --- a/docs/docs/04_client.md +++ b/docs/docs/04_client.md @@ -42,14 +42,15 @@ const data = await res.json(); // data is { userNames: string[] } If the response have multiple status codes, response type is union of each status code type. ```typescript +type Headers = { headers: { 'Content-Type': 'application/json' } }; type Spec = DefineApiEndpoints<{ "/users": { get: { responses: { - 200: { body: { names: string[] }; }; - 201: { body: { ok: boolean }; }; - 400: { body: { message: string; }; }; - 500: { body: { internalError: string; }; }; + 200: { body: { names: string[] }; } & Headers; + 201: { body: { ok: boolean }; } & Headers; + 400: { body: { message: string; }; } & Headers; + 500: { body: { internalError: string; }; } & Headers; }; }; } @@ -61,6 +62,12 @@ if (!res.ok) { // If res.ok is false, status code is 400 or 500 // So res.json() returns { message: string } | { internalError: string } const data = await res.json(); + + // Response headers are also type-checked. Content-Type is always 'application/json' + const contentType: 'application/json' = res.headers.get('Content-Type'); + // and, hasContentType is inferred as true, not boolean + const hasContentType: true = res.headers.has('Content-Type'); + return console.error(data); } // If res.ok is true, status code is 200 or 201 @@ -69,6 +76,20 @@ const data = await res.json(); // names is string[] console.log(data); ``` +:::info[Response headers limitation] + +Response headers are treated as an immutable object for strict type checking. +It means that you can not `append`, `set` or `delete` operation after the response object is created. +This is a limitation of the type system, not a runtime change. If you need mutable operations, you can cast types. + +```typescript +const immutableHeaders = res.headers +const mutableHeaders = res.headers as Headers; +``` + +::: + + ### Path & Path parameters zero-fetch accepts only the path that is defined in the API specification. diff --git a/examples/fastify/zod/fetch.ts b/examples/fastify/zod/fetch.ts index 0a11079..36cca01 100644 --- a/examples/fastify/zod/fetch.ts +++ b/examples/fastify/zod/fetch.ts @@ -50,6 +50,10 @@ const main = async () => { const r = await res.json(); console.log(`${path}:${method} => ${r.userId}`); console.log(res.headers.get("Content-Type")); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const contentType: "application/json" = res.headers.get("Content-Type"); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hasContentType: true = res.headers.has("Content-Type"); } else { // e is the response schema defined in pathMap["/users"]["post"].res other than "20X" const e = await res.text(); diff --git a/examples/spec/zod.ts b/examples/spec/zod.ts index 00a41fa..cf4265f 100644 --- a/examples/spec/zod.ts +++ b/examples/spec/zod.ts @@ -2,8 +2,8 @@ import { z } from "zod"; import { ToApiEndpoints, ZodApiEndpoints } from "../../src"; const JsonHeader = z.union([ - z.object({ "content-type": z.string() }), - z.object({ "Content-Type": z.string() }), + z.object({ "content-type": z.literal("application/json") }), + z.object({ "Content-Type": z.literal("application/json") }), ]); export const pathMap = { "/users": { diff --git a/src/core/headers.t-test.ts b/src/core/headers.t-test.ts new file mode 100644 index 0000000..dd6f66a --- /dev/null +++ b/src/core/headers.t-test.ts @@ -0,0 +1,22 @@ +import { ImmutableHeaders } from "./headers"; + +{ + type ContentType = + | { "Content-Type": "application/json" } + | { "content-type": "application/json" }; + const headers = new Headers({ + "Content-Type": "application/json", + }) as unknown as ImmutableHeaders; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const contentType: "application/json" = headers.get("Content-Type"); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const contentType2: "application/json" = headers.get("content-type"); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hasContentType: true = headers.has("Content-Type"); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const optionalKey: boolean = headers.has("optionalKey"); +} diff --git a/src/core/headers.ts b/src/core/headers.ts new file mode 100644 index 0000000..93f0375 --- /dev/null +++ b/src/core/headers.ts @@ -0,0 +1,16 @@ +import { AllKeys, AllValues, IsOptional } from "./type"; + +export interface ImmutableHeaders> + extends Omit { + get>(name: Name): AllValues; + has>( + name: Name, + ): IsOptional extends true ? boolean : true; + forEach( + callbackfn: & string>( + value: AllValues, + key: Name, + parent: Headers, + ) => void, + ): void; +} diff --git a/src/core/hono-types.ts b/src/core/hono-types.ts index fc520f8..943036e 100644 --- a/src/core/hono-types.ts +++ b/src/core/hono-types.ts @@ -1,3 +1,5 @@ +import { ImmutableHeaders } from "./headers"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any type BlankRecordToNever = T extends any ? T extends null @@ -13,7 +15,8 @@ export interface ClientResponse< T, U extends number = StatusCode, F extends ResponseFormat = ResponseFormat, -> extends globalThis.Response { + H extends Record = Record, +> extends Omit { readonly body: ReadableStream | null; readonly bodyUsed: boolean; ok: U extends SuccessStatusCode @@ -23,7 +26,7 @@ export interface ClientResponse< : boolean; status: U; statusText: string; - headers: Headers; + headers: ImmutableHeaders; url: string; redirect(url: string, status: number): Response; clone(): Response; diff --git a/src/core/spec.ts b/src/core/spec.ts index 0788cd2..8b39ffb 100644 --- a/src/core/spec.ts +++ b/src/core/spec.ts @@ -119,6 +119,12 @@ export type ApiRes< AResponses extends AnyApiResponses, SC extends keyof AResponses & StatusCode, > = AResponses[SC] extends AnyResponse ? AResponses[SC]["body"] : undefined; +export type ApiResHeaders< + AResponses extends AnyApiResponses, + SC extends keyof AResponses & StatusCode, +> = AResponses[SC] extends AnyResponse + ? AResponses[SC]["headers"] + : Record; export type AnyApiResponses = DefineApiResponses; export type DefineApiResponses = Partial< Record @@ -130,7 +136,8 @@ export type ApiClientResponses = { [SC in keyof AResponses & StatusCode]: ClientResponse< ApiRes, SC, - "json" + "json", + ApiResHeaders >; }; export type MergeApiResponseBodies = diff --git a/src/core/type.t-test.ts b/src/core/type.t-test.ts index a4dd0dd..e902f34 100644 --- a/src/core/type.t-test.ts +++ b/src/core/type.t-test.ts @@ -1,10 +1,13 @@ import { Equal, Expect } from "./type-test"; import { + AllKeys, + AllValues, CountChar, ExtractByPrefix, FilterNever, IsAllOptional, IsEqualNumber, + IsOptional, Replace, ReplaceAll, SameSlashNum, @@ -110,6 +113,13 @@ type SameSlashNumTestCases = [ Expect, false>>, ]; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type IsOptionalTestCases = [ + // eslint-disable-next-line @typescript-eslint/ban-types + Expect, false>>, + Expect, true>>, +]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars type IsAllOptionalTestCases = [ // eslint-disable-next-line @typescript-eslint/ban-types @@ -119,3 +129,15 @@ type IsAllOptionalTestCases = [ Expect, false>>, Expect, true>>, ]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type AllKeysTestCases = [ + Expect, "a" | "b">>, + Expect, "a" | "b">>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type AllValuesTestCases = [ + Expect, 1 | 2>>, + Expect, 3>>, +]; diff --git a/src/core/type.ts b/src/core/type.ts index d950a11..a520495 100644 --- a/src/core/type.ts +++ b/src/core/type.ts @@ -143,5 +143,19 @@ export type SameSlashNum = IsEqualNumber< CountChar >; +export type IsOptional = + // eslint-disable-next-line @typescript-eslint/ban-types + {} extends Pick ? true : false; + // eslint-disable-next-line @typescript-eslint/ban-types export type IsAllOptional = {} extends T ? true : false; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AllKeys = T extends any ? keyof T : never; + +export type AllValues> = T extends { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key in Key]?: any; +} + ? T[Key] + : never; diff --git a/src/fetch/index.t-test.ts b/src/fetch/index.t-test.ts index 457ee58..1f2fe9f 100644 --- a/src/fetch/index.t-test.ts +++ b/src/fetch/index.t-test.ts @@ -36,7 +36,12 @@ type ValidateUrlTestCase = [ type Spec = DefineApiEndpoints<{ "/users": { get: { - responses: { 200: { body: { prop: string } } }; + responses: { + 200: { + body: { prop: string }; + headers: { "Content-Type": "application/json" }; + }; + }; }; }; }>; @@ -49,6 +54,11 @@ type ValidateUrlTestCase = [ // methodを省略した場合はgetとして扱う const res = await f("/users", {}); (await res.json()).prop; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const contentType: "application/json" = res.headers.get("Content-Type"); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hasContentType: true = res.headers.has("Content-Type"); } })(); }