From c35a2f5cc2c31b2786eaa77a855b2f5d04400c55 Mon Sep 17 00:00:00 2001 From: mpppk Date: Mon, 30 Sep 2024 10:05:51 +0900 Subject: [PATCH 1/3] Add IsValidUrl type --- src/core/query-string.t-test.ts | 70 +++++++++++++++++++++++++++++++++ src/core/query-string.ts | 37 +++++++++++++++++ src/fetch/index.t-test.ts | 26 ++++++++++++ src/fetch/index.ts | 19 ++++++++- 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/core/query-string.t-test.ts diff --git a/src/core/query-string.t-test.ts b/src/core/query-string.t-test.ts new file mode 100644 index 0000000..5aa1c8c --- /dev/null +++ b/src/core/query-string.t-test.ts @@ -0,0 +1,70 @@ +import { Equal, Expect } from "./type-test"; +import { + HasExcessiveQuery, + HasMissingQuery, + IsValidQuery, + NonOptionalKeys, + ToQueryUnion, +} from "./query-string"; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type ToQueryUnionCase = [ + Expect, "a">>, + Expect, "a" | "b">>, + Expect, "a" | "b">>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type HasMissingQueryCase = [ + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, false>>, + Expect, true>>, + Expect, true>>, + Expect, true>>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type HasExcessiveQueryCase = [ + Expect, false>>, + Expect, true>>, + Expect, true>>, + Expect, false>>, + Expect, false>>, + Expect< + Equal, true> + >, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type NonOptionalKeysCase = [ + Expect, "a">>, + Expect, never>>, + Expect, "a">>, + Expect, "a" | "b">>, +]; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type IsValidQueryCase = [ + Expect, true>>, + Expect, "E: maybe missing query: a">>, + Expect< + Equal< + IsValidQuery<{ a: string }, "a" | "b">, + "E: maybe excessive query: a" | "E: maybe excessive query: b" + > + >, + Expect, true>>, + Expect, true>>, + Expect< + Equal< + IsValidQuery<{ a: string; b: string }, "a">, + "E: maybe missing query: a" | "E: maybe missing query: b" + > + >, + Expect, true>>, +]; diff --git a/src/core/query-string.ts b/src/core/query-string.ts index ad73b30..ea3ec95 100644 --- a/src/core/query-string.ts +++ b/src/core/query-string.ts @@ -38,3 +38,40 @@ export type SetProperty = { ? T[P] : never; }; + +export type ExtractQuery = + URL extends `${string}?${infer Query}` ? Query : undefined; + +export type ToQueryUnion = + Query extends `${infer Key}=${string}&${infer Rest}` + ? Key | ToQueryUnion + : Query extends `${infer Key}=${string}` + ? Key + : `invalid query: ${Query}`; + +export type HasMissingQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record, + QueryKeys extends string, +> = NonOptionalKeys extends QueryKeys ? false : true; + +export type HasExcessiveQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record, + QueryKeys extends string, + // no union distribution +> = [QueryKeys] extends [keyof QueryDef] ? false : true; + +export type NonOptionalKeys = { + [K in keyof T]-?: undefined extends T[K] ? never : K; +}[keyof T]; + +export type IsValidQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record, + QueryKeys extends string, +> = [HasMissingQuery] extends [true] + ? `E: maybe missing query: ${keyof QueryDef & string}` + : [HasExcessiveQuery] extends [true] + ? `E: maybe excessive query: ${QueryKeys}` + : true; diff --git a/src/fetch/index.t-test.ts b/src/fetch/index.t-test.ts index 9eefde4..e75ef16 100644 --- a/src/fetch/index.t-test.ts +++ b/src/fetch/index.t-test.ts @@ -20,6 +20,15 @@ const JSONT = JSON as JSONT; (await res.json()).prop; } })(); + (async () => { + const f = fetch as FetchT<"", Spec>; + { + // TODO: 今はinitの省略ができないが、できるようにしたい + // methodを省略した場合はgetとして扱う + const res = await f("/users", {}); + (await res.json()).prop; + } + })(); } { type Spec = DefineApiEndpoints<{ @@ -220,6 +229,23 @@ const JSONT = JSON as JSONT; headers: { Cookie: "a=b" }, }); } + { + // @ts-expect-error 定義されているパラメータを指定していない場合はエラー + f(`/api/projects/projectA/workflow/packages/list?a=b`, { + headers: { Cookie: "a=b" }, + }); + } + { + // @ts-expect-error 定義されていないパラメータを指定した場合は型エラー + f(`/api/projects/projectA/workflow/packages/list?state=true&a=b`, { + headers: { Cookie: "a=b" }, + }); + + // @ts-expect-error 順序は関係ない + f(`/api/projects/projectA/workflow/packages/list?a=b&state=true`, { + headers: { Cookie: "a=b" }, + }); + } })(); } diff --git a/src/fetch/index.ts b/src/fetch/index.ts index e02927a..42adff1 100644 --- a/src/fetch/index.ts +++ b/src/fetch/index.ts @@ -13,10 +13,25 @@ import { StatusCode, IsAllOptional, CaseInsensitive, + ExtractQuery, + IsValidQuery, + ToQueryUnion, } from "../core"; import { UrlPrefixPattern, ToUrlParamPattern } from "../core"; import { TypedString } from "../json"; +type IsValidUrl< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + QueryDef extends Record | undefined, + Url extends string, + Query extends string | undefined = ExtractQuery, + QueryKeys extends string = Query extends string ? ToQueryUnion : never, +> = IsValidQuery< + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types + QueryDef extends Record ? QueryDef : {}, + QueryKeys +>; + export type RequestInitT< InputMethod extends CaseInsensitiveMethod, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -86,7 +101,7 @@ type FetchT = < ? MergeApiResponseBodies> : Record, >( - input: Input, + input: IsValidUrl extends true ? Input : never, init: RequestInitT< InputMethod, ApiP, @@ -95,3 +110,5 @@ type FetchT = < ) => Promise; export default FetchT; + +// type ValidUrlAndQuery = Query extends `${string}?${string}` From 92cdb1bba6c4b5da24eb8497d6dd3b1604a49196 Mon Sep 17 00:00:00 2001 From: mpppk Date: Mon, 30 Sep 2024 10:20:03 +0900 Subject: [PATCH 2/3] Fix --- src/fetch/index.t-test.ts | 9 --------- src/fetch/index.ts | 2 -- 2 files changed, 11 deletions(-) diff --git a/src/fetch/index.t-test.ts b/src/fetch/index.t-test.ts index e75ef16..f2f99ab 100644 --- a/src/fetch/index.t-test.ts +++ b/src/fetch/index.t-test.ts @@ -20,15 +20,6 @@ const JSONT = JSON as JSONT; (await res.json()).prop; } })(); - (async () => { - const f = fetch as FetchT<"", Spec>; - { - // TODO: 今はinitの省略ができないが、できるようにしたい - // methodを省略した場合はgetとして扱う - const res = await f("/users", {}); - (await res.json()).prop; - } - })(); } { type Spec = DefineApiEndpoints<{ diff --git a/src/fetch/index.ts b/src/fetch/index.ts index 42adff1..65a2157 100644 --- a/src/fetch/index.ts +++ b/src/fetch/index.ts @@ -110,5 +110,3 @@ type FetchT = < ) => Promise; export default FetchT; - -// type ValidUrlAndQuery = Query extends `${string}?${string}` From 44d5c11459f7ab6e1184b2695abc5b6053f8921e Mon Sep 17 00:00:00 2001 From: mpppk Date: Mon, 30 Sep 2024 10:29:08 +0900 Subject: [PATCH 3/3] Record to Record --- src/fetch/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/fetch/index.ts b/src/fetch/index.ts index 65a2157..98ed876 100644 --- a/src/fetch/index.ts +++ b/src/fetch/index.ts @@ -22,7 +22,7 @@ import { TypedString } from "../json"; type IsValidUrl< // eslint-disable-next-line @typescript-eslint/no-explicit-any - QueryDef extends Record | undefined, + QueryDef extends Record | undefined, Url extends string, Query extends string | undefined = ExtractQuery, QueryKeys extends string = Query extends string ? ToQueryUnion : never, @@ -34,15 +34,13 @@ type IsValidUrl< export type RequestInitT< InputMethod extends CaseInsensitiveMethod, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Body extends Record | string | undefined, + Body extends Record | string | undefined, HeadersObj extends string | Record | undefined, > = Omit & (InputMethod extends "get" | "GET" ? { method?: InputMethod } : { method: InputMethod }) & - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (Body extends Record + (Body extends Record ? IsAllOptional extends true ? { body?: Body | TypedString } : { body: TypedString }