Skip to content

Commit 749d4bb

Browse files
feat: Implement typed URLSearchParams (#19)
1 parent a8c6ceb commit 749d4bb

11 files changed

+363
-72
lines changed

.github/workflows/checks.yml

+9-30
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,16 @@ jobs:
1010
fmt:
1111
runs-on: ubuntu-latest
1212
steps:
13-
- name: Checkout sources
14-
uses: actions/checkout@v2
15-
16-
- name: Setup latest deno version
17-
uses: denoland/setup-deno@v1
18-
with:
19-
deno-version: v1.x
20-
21-
- name: Run deno fmt
22-
run: deno fmt --check
13+
- uses: actions/checkout@v4
14+
- uses: denoland/setup-deno@v2
15+
- run: deno fmt --check
2316

2417
lint:
2518
runs-on: ubuntu-latest
2619
steps:
27-
- name: Checkout sources
28-
uses: actions/checkout@v2
29-
30-
- name: Setup latest deno version
31-
uses: denoland/setup-deno@v1
32-
with:
33-
deno-version: v1.x
34-
35-
- name: Run deno lint
36-
run: deno lint
20+
- uses: actions/checkout@v4
21+
- uses: denoland/setup-deno@v2
22+
- run: deno lint
3723

3824
check:
3925
runs-on: ubuntu-latest
@@ -52,13 +38,6 @@ jobs:
5238
test:
5339
runs-on: ubuntu-latest
5440
steps:
55-
- name: Checkout sources
56-
uses: actions/checkout@v2
57-
58-
- name: Setup latest deno version
59-
uses: denoland/setup-deno@v1
60-
with:
61-
deno-version: v1.x
62-
63-
- name: Run deno test
64-
run: deno test -A
41+
- uses: actions/checkout@v4
42+
- uses: denoland/setup-deno@v2
43+
- run: deno test -A

.github/workflows/publish.yml

+4-4
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ jobs:
2020
with:
2121
node-version: "20.x"
2222
registry-url: "https://registry.npmjs.org"
23-
- uses: denoland/setup-deno@v1
23+
- uses: denoland/setup-deno@v2
2424
- run: |
25-
echo "DENO_VERSION=$(cat deno.json | jq \".version\")" >> $GITHUB_ENV
26-
echo "NPM_VERSION=$(npm info @denosaurs/typefetch --json | jq \".['dist-tags'].latest\")" >> $GITHUB_ENV
27-
echo "JSR_VERSION=$(curl -s https://jsr.io/@denosaurs/typefetch/meta.json | jq \".latest\")" >> $GITHUB_ENV
25+
echo "DENO_VERSION=$(cat deno.json | jq \".version\")" >> $GITHUB_ENV
26+
echo "NPM_VERSION=$(npm info @denosaurs/typefetch --json | jq \".['dist-tags'].latest\")" >> $GITHUB_ENV
27+
echo "JSR_VERSION=$(curl -s https://jsr.io/@denosaurs/typefetch/meta.json | jq \".latest\")" >> $GITHUB_ENV
2828
- run: deno publish
2929
if: ${{ env.DENO_VERSION != env.JSR_VERSION }}
3030
- run: deno run -A scripts/npm.ts

deno.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@denosaurs/typefetch",
3-
"version": "0.0.30",
3+
"version": "0.0.31",
44
"exports": {
55
".": "./main.ts"
66
},

main.ts

+16-7
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ if (args.help) {
6363
` --include-server-urls Include server URLs from the schema in the generated paths (default: ${parseOptions.default["include-server-urls"]})\n` +
6464
` --include-absolute-url Include absolute URLs in the generated paths (default: ${parseOptions.default["include-absolute-url"]})\n` +
6565
` --include-relative-url Include relative URLs in the generated paths (default: ${parseOptions.default["include-relative-url"]})\n` +
66-
` --experimental-urlsearchparams Enable the experimental fully typed URLSearchParams type (default: ${parseOptions.default["experimental-urlsearchparams"]})\n`,
66+
` --experimental-urlsearchparams Enable the experimental fully typed URLSearchParamsString type (default: ${parseOptions.default["experimental-urlsearchparams"]})\n`,
6767
);
6868
Deno.exit(0);
6969
}
@@ -104,6 +104,7 @@ try {
104104
}
105105
importSpinner.succeed("Schema resolved");
106106

107+
const baseImport = args.import.replace(/\/$/, "");
107108
const options = {
108109
baseUrls: args["base-urls"]?.split(","),
109110
includeAbsoluteUrl: args["include-absolute-url"],
@@ -119,25 +120,33 @@ const source = project.createSourceFile(output, undefined, {
119120

120121
source.addImportDeclaration({
121122
isTypeOnly: true,
122-
moduleSpecifier: `${args["import"]}/types/json${
123-
URL.canParse(args["import"]) ? ".ts" : ""
123+
moduleSpecifier: `${baseImport}/types/json${
124+
URL.canParse(baseImport) ? ".ts" : ""
124125
}`,
125126
namedImports: ["JSONString"],
126127
});
127128

128129
source.addImportDeclaration({
129130
isTypeOnly: true,
130-
moduleSpecifier: `${args["import"]}/types/headers${
131-
URL.canParse(args["import"]) ? ".ts" : ""
131+
moduleSpecifier: `${baseImport}/types/headers${
132+
URL.canParse(baseImport) ? ".ts" : ""
132133
}`,
133134
namedImports: ["TypedHeadersInit"],
134135
});
135136

136137
if (options.experimentalURLSearchParams) {
137138
source.addImportDeclaration({
138139
isTypeOnly: true,
139-
moduleSpecifier: `${args["import"]}/types/urlsearchparams${
140-
URL.canParse(args["import"]) ? ".ts" : ""
140+
moduleSpecifier: `${baseImport}/types/url_search_params_string${
141+
URL.canParse(baseImport) ? ".ts" : ""
142+
}`,
143+
namedImports: ["URLSearchParamsString"],
144+
});
145+
} else {
146+
source.addImportDeclaration({
147+
isTypeOnly: true,
148+
moduleSpecifier: `${baseImport}/types/url_search_params${
149+
URL.canParse(baseImport) ? ".ts" : ""
141150
}`,
142151
namedImports: ["URLSearchParamsString"],
143152
});

mod.ts

+63-25
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,28 @@ export function toSchemaType(
6161
schema?:
6262
| OpenAPI.ReferenceObject
6363
| OpenAPI.SchemaObject,
64+
coerceToString?: boolean,
6465
): string | undefined {
6566
if (schema === undefined) return undefined;
6667
if ("$ref" in schema) return pascalCase(schema.$ref.split("/").pop()!);
6768

6869
if ("nullable" in schema && schema.nullable !== undefined) {
69-
const type = toSchemaType(document, { ...schema, nullable: undefined });
70+
const type = toSchemaType(
71+
document,
72+
{ ...schema, nullable: undefined },
73+
coerceToString,
74+
);
7075
if (type !== undefined) return `${type}|null`;
7176
return "null";
7277
}
7378

7479
if (schema.not !== undefined) {
75-
const type = toSchemaType(document, { ...schema, not: undefined });
76-
const exclude = toSchemaType(document, schema.not);
80+
const type = toSchemaType(
81+
document,
82+
{ ...schema, not: undefined },
83+
coerceToString,
84+
);
85+
const exclude = toSchemaType(document, schema.not, coerceToString);
7786
if (type !== undefined && exclude !== undefined) {
7887
return `Exclude<${type}, ${exclude}>`;
7988
}
@@ -85,12 +94,13 @@ export function toSchemaType(
8594
const type = toSchemaType(document, {
8695
...schema,
8796
additionalProperties: undefined,
88-
});
97+
}, coerceToString);
8998
let additionalProperties;
9099
if (schema.additionalProperties !== true) {
91100
additionalProperties = toSchemaType(
92101
document,
93102
schema.additionalProperties,
103+
coerceToString,
94104
);
95105
}
96106
if (type !== undefined) {
@@ -101,14 +111,14 @@ export function toSchemaType(
101111

102112
if (schema.allOf) {
103113
return schema.allOf
104-
.map((schema) => toSchemaType(document, schema))
114+
.map((schema) => toSchemaType(document, schema, coerceToString))
105115
.filter(Boolean)
106116
.join("&");
107117
}
108118

109119
if (schema.oneOf) {
110120
return schema.oneOf
111-
.map((schema) => toSchemaType(document, schema))
121+
.map((schema) => toSchemaType(document, schema, coerceToString))
112122
.map((type, _, types) => toSafeUnionString(type, types))
113123
.filter(Boolean)
114124
.join("|");
@@ -129,7 +139,7 @@ export function toSchemaType(
129139
}
130140

131141
return schema.anyOf
132-
.map((schema) => toSchemaType(document, schema))
142+
.map((schema) => toSchemaType(document, schema, coerceToString))
133143
.map((type, _, types) => toSafeUnionString(type, types))
134144
.filter(Boolean)
135145
.join("|");
@@ -141,11 +151,13 @@ export function toSchemaType(
141151

142152
switch (schema.type) {
143153
case "boolean":
154+
if (coerceToString) return "`${boolean}`";
144155
return "boolean";
145156
case "string":
146157
return "string";
147158
case "number":
148159
case "integer":
160+
if (coerceToString) return "`${number}`";
149161
return "number";
150162
case "object": {
151163
if ("properties" in schema && schema.properties !== undefined) {
@@ -157,19 +169,24 @@ export function toSchemaType(
157169
.map(([property, type]) =>
158170
`${escapeObjectKey(property)}${
159171
schema.required?.includes(property) ? "" : "?"
160-
}:${toSchemaType(document, type)}`
172+
}:${toSchemaType(document, type, coerceToString)}`
161173
)
162174
.join(";")
163175
}}`;
164176
}
177+
178+
if (coerceToString) return "Record<string, string>";
165179
return "Record<string, unknown>";
166180
}
167181
case "array": {
168-
const items = toSchemaType(document, schema.items);
182+
const items = toSchemaType(document, schema.items, coerceToString);
169183
if (items !== undefined) return `(${items})[]`;
184+
185+
if (coerceToString) return "string[]";
170186
return "unknown[]";
171187
}
172188
case "null":
189+
if (coerceToString) return "`${null}`";
173190
return "null";
174191
}
175192

@@ -300,25 +317,43 @@ export function createRequestBodyType(
300317
document: OpenAPI.Document,
301318
contentType: string,
302319
schema?: OpenAPI.SchemaObject | OpenAPI.ReferenceObject,
320+
options?: Options,
303321
): string {
304322
let type = "BodyInit";
305323

306324
switch (contentType) {
307-
case "application/json":
325+
case "application/json": {
308326
type = `JSONString<${toSchemaType(document, schema) ?? "unknown"}>`;
309327
break;
310-
case "text/plain":
328+
}
329+
case "text/plain": {
311330
type = "string";
312331
break;
313-
case "multipart/form-data":
332+
}
333+
case "multipart/form-data": {
314334
type = "FormData";
315335
break;
316-
case "application/x-www-form-urlencoded":
317-
type = "URLSearchParams";
336+
}
337+
case "application/x-www-form-urlencoded": {
338+
const schemaType = toSchemaType(document, schema, true);
339+
if (schemaType !== undefined) {
340+
const types = [`URLSearchParamsString<${schemaType}>`];
341+
342+
// TODO: We don't yet support URLSearchParams with the --experimental-urlsearchparams flag
343+
if (!options?.experimentalURLSearchParams) {
344+
types.push(`URLSearchParams<${schemaType}>`);
345+
}
346+
347+
return `(${types.join("|")})`;
348+
} else {
349+
type = `URLSearchParams`;
350+
}
318351
break;
319-
case "application/octet-stream":
352+
}
353+
case "application/octet-stream": {
320354
type = "ReadableStream | Blob | BufferSource";
321355
break;
356+
}
322357
}
323358

324359
return type;
@@ -385,7 +420,6 @@ export function toTemplateString(
385420
document: OpenAPI.Document,
386421
pattern: string,
387422
parameters: ParameterObjectMap,
388-
options: Options,
389423
): string {
390424
let patternTemplateString = pattern;
391425
let urlSearchParamsOptional = true;
@@ -397,7 +431,9 @@ export function toTemplateString(
397431
urlSearchParamsOptional = false;
398432
}
399433

400-
const types = [toSchemaType(document, parameter.schema) ?? "string"];
434+
const types = [
435+
toSchemaType(document, parameter.schema, true) ?? "string",
436+
];
401437
if (parameter.allowEmptyValue === true) types.push("true");
402438
urlSearchParamsRecord.push(
403439
`${escapeObjectKey(parameter.name)}${!parameter.required ? "?" : ""}: ${
@@ -414,15 +450,17 @@ export function toTemplateString(
414450
);
415451
}
416452

417-
const URLSearchParams = urlSearchParamsRecord.length > 0
418-
? options.experimentalURLSearchParams
419-
? `\${URLSearchParamsString<{${urlSearchParamsRecord.join(";")}}>}`
420-
: urlSearchParamsOptional
421-
? '${"" | `?${string}`}'
422-
: "?${string}"
453+
const urlSearchParamsType = urlSearchParamsRecord.length > 0
454+
? `URLSearchParamsString<{${urlSearchParamsRecord.join(";")}}>`
455+
: undefined;
456+
457+
const urlSearchParams = urlSearchParamsType
458+
? urlSearchParamsOptional
459+
? `\${\`?\${${urlSearchParamsType}}\` | ""}`
460+
: `?\${${urlSearchParamsType}}`
423461
: "";
424462

425-
return `${patternTemplateString}${URLSearchParams}`;
463+
return `${patternTemplateString}${urlSearchParams}`;
426464
}
427465

428466
export function toHeadersInitType(
@@ -569,7 +607,7 @@ export function addOperationObject(
569607
doc.tags.push({ tagName: "summary", text: operation.summary.trim() });
570608
}
571609

572-
const path = toTemplateString(document, pattern, parameters, options);
610+
const path = toTemplateString(document, pattern, parameters);
573611

574612
const inputs = [];
575613

scripts/npm.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ await build({
2424
},
2525
{
2626
kind: "export",
27-
name: "./types/urlsearchparams",
28-
path: "./types/urlsearchparams.ts",
27+
name: "./types/url_search_params",
28+
path: "./types/url_search_params.ts",
29+
},
30+
{
31+
kind: "export",
32+
name: "./types/url_search_params_string",
33+
path: "./types/url_search_params_string.ts",
2934
},
3035
],
3136
filterDiagnostic: (diagnostic) => {
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Equal, Expect, IsUnion, NotEqual } from "npm:type-testing";
2+
import type { Error, Pets } from "./schemas/petstore.json.ts";
3+
4+
import { URLSearchParams } from "../../types/url_search_params.ts";
5+
6+
const urlSearchParams = new URLSearchParams<{ limit?: `${number}` }>({
7+
limit: "10",
8+
});
9+
const response = await fetch(
10+
`http://petstore.swagger.io/v1/pets?${urlSearchParams.toString()}`,
11+
);
12+
13+
if (response.ok) {
14+
const json = await response.json();
15+
type test_IsUnion = Expect<IsUnion<typeof json>>;
16+
type test_IsPetsOrError = Expect<Equal<typeof json, Pets | Error>>;
17+
}
18+
19+
if (response.status === 200) {
20+
const pets = await response.json();
21+
type test_IsPets = Expect<Equal<typeof pets, Pets>>;
22+
type test_IsNotError = Expect<NotEqual<typeof pets, Error>>;
23+
}

0 commit comments

Comments
 (0)