Skip to content

Commit

Permalink
Res header (#126)
Browse files Browse the repository at this point in the history
* Add ImmutableHeaders

* Test headers type inference

* Add doc
  • Loading branch information
mpppk authored Oct 20, 2024
1 parent 24490bb commit 04f9779
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 10 deletions.
29 changes: 25 additions & 4 deletions docs/docs/04_client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
}
Expand All @@ -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
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions examples/fastify/zod/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions examples/spec/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 22 additions & 0 deletions src/core/headers.t-test.ts
Original file line number Diff line number Diff line change
@@ -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<ContentType & { optionalKey?: string }>;

// 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");
}
16 changes: 16 additions & 0 deletions src/core/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AllKeys, AllValues, IsOptional } from "./type";

export interface ImmutableHeaders<H extends Record<string, string>>
extends Omit<Headers, "set" | "append" | "delete"> {
get<Name extends AllKeys<H>>(name: Name): AllValues<H, Name>;
has<Name extends AllKeys<H>>(
name: Name,
): IsOptional<H, Name> extends true ? boolean : true;
forEach(
callbackfn: <Name extends AllKeys<H> & string>(
value: AllValues<H, Name>,
key: Name,
parent: Headers,
) => void,
): void;
}
7 changes: 5 additions & 2 deletions src/core/hono-types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ImmutableHeaders } from "./headers";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type BlankRecordToNever<T> = T extends any
? T extends null
Expand All @@ -13,7 +15,8 @@ export interface ClientResponse<
T,
U extends number = StatusCode,
F extends ResponseFormat = ResponseFormat,
> extends globalThis.Response {
H extends Record<string, string> = Record<string, string>,
> extends Omit<globalThis.Response, "headers"> {
readonly body: ReadableStream | null;
readonly bodyUsed: boolean;
ok: U extends SuccessStatusCode
Expand All @@ -23,7 +26,7 @@ export interface ClientResponse<
: boolean;
status: U;
statusText: string;
headers: Headers;
headers: ImmutableHeaders<H>;
url: string;
redirect(url: string, status: number): Response;
clone(): Response;
Expand Down
9 changes: 8 additions & 1 deletion src/core/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, never>;
export type AnyApiResponses = DefineApiResponses<AnyResponse>;
export type DefineApiResponses<Response extends AnyResponse> = Partial<
Record<StatusCode, Response>
Expand All @@ -130,7 +136,8 @@ export type ApiClientResponses<AResponses extends AnyApiResponses> = {
[SC in keyof AResponses & StatusCode]: ClientResponse<
ApiRes<AResponses, SC>,
SC,
"json"
"json",
ApiResHeaders<AResponses, SC>
>;
};
export type MergeApiResponseBodies<AR extends AnyApiResponses> =
Expand Down
22 changes: 22 additions & 0 deletions src/core/type.t-test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Equal, Expect } from "./type-test";
import {
AllKeys,
AllValues,
CountChar,
ExtractByPrefix,
FilterNever,
IsAllOptional,
IsEqualNumber,
IsOptional,
Replace,
ReplaceAll,
SameSlashNum,
Expand Down Expand Up @@ -110,6 +113,13 @@ type SameSlashNumTestCases = [
Expect<Equal<SameSlashNum<`/${string}`, "/a/b">, false>>,
];

// eslint-disable-next-line @typescript-eslint/no-unused-vars
type IsOptionalTestCases = [
// eslint-disable-next-line @typescript-eslint/ban-types
Expect<Equal<IsOptional<{ a: string; b?: string }, "a">, false>>,
Expect<Equal<IsOptional<{ a: string; b?: string }, "b">, true>>,
];

// eslint-disable-next-line @typescript-eslint/no-unused-vars
type IsAllOptionalTestCases = [
// eslint-disable-next-line @typescript-eslint/ban-types
Expand All @@ -119,3 +129,15 @@ type IsAllOptionalTestCases = [
Expect<Equal<IsAllOptional<{ a?: string; b: string }>, false>>,
Expect<Equal<IsAllOptional<{ a?: string; b?: string }>, true>>,
];

// eslint-disable-next-line @typescript-eslint/no-unused-vars
type AllKeysTestCases = [
Expect<Equal<AllKeys<{ a: string } | { b: string }>, "a" | "b">>,
Expect<Equal<AllKeys<{ a: string } | { a: string; b: string }>, "a" | "b">>,
];

// eslint-disable-next-line @typescript-eslint/no-unused-vars
type AllValuesTestCases = [
Expect<Equal<AllValues<{ a: 1 } | { a: 2 }, "a">, 1 | 2>>,
Expect<Equal<AllValues<{ a: 1; b: 3 } | { a: 2 }, "b">, 3>>,
];
14 changes: 14 additions & 0 deletions src/core/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,5 +143,19 @@ export type SameSlashNum<P1 extends string, P2 extends string> = IsEqualNumber<
CountChar<P2, "/">
>;

export type IsOptional<T, K extends keyof T> =
// eslint-disable-next-line @typescript-eslint/ban-types
{} extends Pick<T, K> ? true : false;

// eslint-disable-next-line @typescript-eslint/ban-types
export type IsAllOptional<T> = {} extends T ? true : false;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AllKeys<T> = T extends any ? keyof T : never;

export type AllValues<T, Key extends AllKeys<T>> = T extends {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key in Key]?: any;
}
? T[Key]
: never;
12 changes: 11 additions & 1 deletion src/fetch/index.t-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
};
};
};
};
}>;
Expand All @@ -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");
}
})();
}
Expand Down

0 comments on commit 04f9779

Please sign in to comment.