Skip to content

Commit

Permalink
feat: Implement typed URLSearchParams (#19)
Browse files Browse the repository at this point in the history
  • Loading branch information
eliassjogreen authored Dec 18, 2024
1 parent a8c6ceb commit 749d4bb
Show file tree
Hide file tree
Showing 11 changed files with 363 additions and 72 deletions.
39 changes: 9 additions & 30 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,16 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2

- name: Setup latest deno version
uses: denoland/setup-deno@v1
with:
deno-version: v1.x

- name: Run deno fmt
run: deno fmt --check
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
- run: deno fmt --check

lint:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2

- name: Setup latest deno version
uses: denoland/setup-deno@v1
with:
deno-version: v1.x

- name: Run deno lint
run: deno lint
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
- run: deno lint

check:
runs-on: ubuntu-latest
Expand All @@ -52,13 +38,6 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2

- name: Setup latest deno version
uses: denoland/setup-deno@v1
with:
deno-version: v1.x

- name: Run deno test
run: deno test -A
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
- run: deno test -A
8 changes: 4 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ jobs:
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
- uses: denoland/setup-deno@v1
- uses: denoland/setup-deno@v2
- run: |
echo "DENO_VERSION=$(cat deno.json | jq \".version\")" >> $GITHUB_ENV
echo "NPM_VERSION=$(npm info @denosaurs/typefetch --json | jq \".['dist-tags'].latest\")" >> $GITHUB_ENV
echo "JSR_VERSION=$(curl -s https://jsr.io/@denosaurs/typefetch/meta.json | jq \".latest\")" >> $GITHUB_ENV
echo "DENO_VERSION=$(cat deno.json | jq \".version\")" >> $GITHUB_ENV
echo "NPM_VERSION=$(npm info @denosaurs/typefetch --json | jq \".['dist-tags'].latest\")" >> $GITHUB_ENV
echo "JSR_VERSION=$(curl -s https://jsr.io/@denosaurs/typefetch/meta.json | jq \".latest\")" >> $GITHUB_ENV
- run: deno publish
if: ${{ env.DENO_VERSION != env.JSR_VERSION }}
- run: deno run -A scripts/npm.ts
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@denosaurs/typefetch",
"version": "0.0.30",
"version": "0.0.31",
"exports": {
".": "./main.ts"
},
Expand Down
23 changes: 16 additions & 7 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ if (args.help) {
` --include-server-urls Include server URLs from the schema in the generated paths (default: ${parseOptions.default["include-server-urls"]})\n` +
` --include-absolute-url Include absolute URLs in the generated paths (default: ${parseOptions.default["include-absolute-url"]})\n` +
` --include-relative-url Include relative URLs in the generated paths (default: ${parseOptions.default["include-relative-url"]})\n` +
` --experimental-urlsearchparams Enable the experimental fully typed URLSearchParams type (default: ${parseOptions.default["experimental-urlsearchparams"]})\n`,
` --experimental-urlsearchparams Enable the experimental fully typed URLSearchParamsString type (default: ${parseOptions.default["experimental-urlsearchparams"]})\n`,
);
Deno.exit(0);
}
Expand Down Expand Up @@ -104,6 +104,7 @@ try {
}
importSpinner.succeed("Schema resolved");

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

source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: `${args["import"]}/types/json${
URL.canParse(args["import"]) ? ".ts" : ""
moduleSpecifier: `${baseImport}/types/json${
URL.canParse(baseImport) ? ".ts" : ""
}`,
namedImports: ["JSONString"],
});

source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: `${args["import"]}/types/headers${
URL.canParse(args["import"]) ? ".ts" : ""
moduleSpecifier: `${baseImport}/types/headers${
URL.canParse(baseImport) ? ".ts" : ""
}`,
namedImports: ["TypedHeadersInit"],
});

if (options.experimentalURLSearchParams) {
source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: `${args["import"]}/types/urlsearchparams${
URL.canParse(args["import"]) ? ".ts" : ""
moduleSpecifier: `${baseImport}/types/url_search_params_string${
URL.canParse(baseImport) ? ".ts" : ""
}`,
namedImports: ["URLSearchParamsString"],
});
} else {
source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: `${baseImport}/types/url_search_params${
URL.canParse(baseImport) ? ".ts" : ""
}`,
namedImports: ["URLSearchParamsString"],
});
Expand Down
88 changes: 63 additions & 25 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,28 @@ export function toSchemaType(
schema?:
| OpenAPI.ReferenceObject
| OpenAPI.SchemaObject,
coerceToString?: boolean,
): string | undefined {
if (schema === undefined) return undefined;
if ("$ref" in schema) return pascalCase(schema.$ref.split("/").pop()!);

if ("nullable" in schema && schema.nullable !== undefined) {
const type = toSchemaType(document, { ...schema, nullable: undefined });
const type = toSchemaType(
document,
{ ...schema, nullable: undefined },
coerceToString,
);
if (type !== undefined) return `${type}|null`;
return "null";
}

if (schema.not !== undefined) {
const type = toSchemaType(document, { ...schema, not: undefined });
const exclude = toSchemaType(document, schema.not);
const type = toSchemaType(
document,
{ ...schema, not: undefined },
coerceToString,
);
const exclude = toSchemaType(document, schema.not, coerceToString);
if (type !== undefined && exclude !== undefined) {
return `Exclude<${type}, ${exclude}>`;
}
Expand All @@ -85,12 +94,13 @@ export function toSchemaType(
const type = toSchemaType(document, {
...schema,
additionalProperties: undefined,
});
}, coerceToString);
let additionalProperties;
if (schema.additionalProperties !== true) {
additionalProperties = toSchemaType(
document,
schema.additionalProperties,
coerceToString,
);
}
if (type !== undefined) {
Expand All @@ -101,14 +111,14 @@ export function toSchemaType(

if (schema.allOf) {
return schema.allOf
.map((schema) => toSchemaType(document, schema))
.map((schema) => toSchemaType(document, schema, coerceToString))
.filter(Boolean)
.join("&");
}

if (schema.oneOf) {
return schema.oneOf
.map((schema) => toSchemaType(document, schema))
.map((schema) => toSchemaType(document, schema, coerceToString))
.map((type, _, types) => toSafeUnionString(type, types))
.filter(Boolean)
.join("|");
Expand All @@ -129,7 +139,7 @@ export function toSchemaType(
}

return schema.anyOf
.map((schema) => toSchemaType(document, schema))
.map((schema) => toSchemaType(document, schema, coerceToString))
.map((type, _, types) => toSafeUnionString(type, types))
.filter(Boolean)
.join("|");
Expand All @@ -141,11 +151,13 @@ export function toSchemaType(

switch (schema.type) {
case "boolean":
if (coerceToString) return "`${boolean}`";
return "boolean";
case "string":
return "string";
case "number":
case "integer":
if (coerceToString) return "`${number}`";
return "number";
case "object": {
if ("properties" in schema && schema.properties !== undefined) {
Expand All @@ -157,19 +169,24 @@ export function toSchemaType(
.map(([property, type]) =>
`${escapeObjectKey(property)}${
schema.required?.includes(property) ? "" : "?"
}:${toSchemaType(document, type)}`
}:${toSchemaType(document, type, coerceToString)}`
)
.join(";")
}}`;
}

if (coerceToString) return "Record<string, string>";
return "Record<string, unknown>";
}
case "array": {
const items = toSchemaType(document, schema.items);
const items = toSchemaType(document, schema.items, coerceToString);
if (items !== undefined) return `(${items})[]`;

if (coerceToString) return "string[]";
return "unknown[]";
}
case "null":
if (coerceToString) return "`${null}`";
return "null";
}

Expand Down Expand Up @@ -300,25 +317,43 @@ export function createRequestBodyType(
document: OpenAPI.Document,
contentType: string,
schema?: OpenAPI.SchemaObject | OpenAPI.ReferenceObject,
options?: Options,
): string {
let type = "BodyInit";

switch (contentType) {
case "application/json":
case "application/json": {
type = `JSONString<${toSchemaType(document, schema) ?? "unknown"}>`;
break;
case "text/plain":
}
case "text/plain": {
type = "string";
break;
case "multipart/form-data":
}
case "multipart/form-data": {
type = "FormData";
break;
case "application/x-www-form-urlencoded":
type = "URLSearchParams";
}
case "application/x-www-form-urlencoded": {
const schemaType = toSchemaType(document, schema, true);
if (schemaType !== undefined) {
const types = [`URLSearchParamsString<${schemaType}>`];

// TODO: We don't yet support URLSearchParams with the --experimental-urlsearchparams flag
if (!options?.experimentalURLSearchParams) {
types.push(`URLSearchParams<${schemaType}>`);
}

return `(${types.join("|")})`;
} else {
type = `URLSearchParams`;
}
break;
case "application/octet-stream":
}
case "application/octet-stream": {
type = "ReadableStream | Blob | BufferSource";
break;
}
}

return type;
Expand Down Expand Up @@ -385,7 +420,6 @@ export function toTemplateString(
document: OpenAPI.Document,
pattern: string,
parameters: ParameterObjectMap,
options: Options,
): string {
let patternTemplateString = pattern;
let urlSearchParamsOptional = true;
Expand All @@ -397,7 +431,9 @@ export function toTemplateString(
urlSearchParamsOptional = false;
}

const types = [toSchemaType(document, parameter.schema) ?? "string"];
const types = [
toSchemaType(document, parameter.schema, true) ?? "string",
];
if (parameter.allowEmptyValue === true) types.push("true");
urlSearchParamsRecord.push(
`${escapeObjectKey(parameter.name)}${!parameter.required ? "?" : ""}: ${
Expand All @@ -414,15 +450,17 @@ export function toTemplateString(
);
}

const URLSearchParams = urlSearchParamsRecord.length > 0
? options.experimentalURLSearchParams
? `\${URLSearchParamsString<{${urlSearchParamsRecord.join(";")}}>}`
: urlSearchParamsOptional
? '${"" | `?${string}`}'
: "?${string}"
const urlSearchParamsType = urlSearchParamsRecord.length > 0
? `URLSearchParamsString<{${urlSearchParamsRecord.join(";")}}>`
: undefined;

const urlSearchParams = urlSearchParamsType
? urlSearchParamsOptional
? `\${\`?\${${urlSearchParamsType}}\` | ""}`
: `?\${${urlSearchParamsType}}`
: "";

return `${patternTemplateString}${URLSearchParams}`;
return `${patternTemplateString}${urlSearchParams}`;
}

export function toHeadersInitType(
Expand Down Expand Up @@ -569,7 +607,7 @@ export function addOperationObject(
doc.tags.push({ tagName: "summary", text: operation.summary.trim() });
}

const path = toTemplateString(document, pattern, parameters, options);
const path = toTemplateString(document, pattern, parameters);

const inputs = [];

Expand Down
9 changes: 7 additions & 2 deletions scripts/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ await build({
},
{
kind: "export",
name: "./types/urlsearchparams",
path: "./types/urlsearchparams.ts",
name: "./types/url_search_params",
path: "./types/url_search_params.ts",
},
{
kind: "export",
name: "./types/url_search_params_string",
path: "./types/url_search_params_string.ts",
},
],
filterDiagnostic: (diagnostic) => {
Expand Down
23 changes: 23 additions & 0 deletions tests/petstore/test_url_search_params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Equal, Expect, IsUnion, NotEqual } from "npm:type-testing";
import type { Error, Pets } from "./schemas/petstore.json.ts";

import { URLSearchParams } from "../../types/url_search_params.ts";

const urlSearchParams = new URLSearchParams<{ limit?: `${number}` }>({
limit: "10",
});
const response = await fetch(
`http://petstore.swagger.io/v1/pets?${urlSearchParams.toString()}`,
);

if (response.ok) {
const json = await response.json();
type test_IsUnion = Expect<IsUnion<typeof json>>;
type test_IsPetsOrError = Expect<Equal<typeof json, Pets | Error>>;
}

if (response.status === 200) {
const pets = await response.json();
type test_IsPets = Expect<Equal<typeof pets, Pets>>;
type test_IsNotError = Expect<NotEqual<typeof pets, Error>>;
}
Loading

0 comments on commit 749d4bb

Please sign in to comment.