diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 58fafa8..90499c0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 797f1ff..a8cfee9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/deno.json b/deno.json index 01d476d..1b9b446 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@denosaurs/typefetch", - "version": "0.0.30", + "version": "0.0.31", "exports": { ".": "./main.ts" }, diff --git a/main.ts b/main.ts index 9236587..a66c9a5 100644 --- a/main.ts +++ b/main.ts @@ -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); } @@ -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"], @@ -119,16 +120,16 @@ 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"], }); @@ -136,8 +137,16 @@ source.addImportDeclaration({ 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"], }); diff --git a/mod.ts b/mod.ts index 1d6d8f0..24ab635 100644 --- a/mod.ts +++ b/mod.ts @@ -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}>`; } @@ -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) { @@ -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("|"); @@ -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("|"); @@ -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) { @@ -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"; return "Record"; } 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"; } @@ -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; @@ -385,7 +420,6 @@ export function toTemplateString( document: OpenAPI.Document, pattern: string, parameters: ParameterObjectMap, - options: Options, ): string { let patternTemplateString = pattern; let urlSearchParamsOptional = true; @@ -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 ? "?" : ""}: ${ @@ -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( @@ -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 = []; diff --git a/scripts/npm.ts b/scripts/npm.ts index 6264a90..63274cc 100644 --- a/scripts/npm.ts +++ b/scripts/npm.ts @@ -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) => { diff --git a/tests/petstore/test_url_search_params.ts b/tests/petstore/test_url_search_params.ts new file mode 100644 index 0000000..d9576d0 --- /dev/null +++ b/tests/petstore/test_url_search_params.ts @@ -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>; + type test_IsPetsOrError = Expect>; +} + +if (response.status === 200) { + const pets = await response.json(); + type test_IsPets = Expect>; + type test_IsNotError = Expect>; +} diff --git a/types/headers.ts b/types/headers.ts index d03b9cb..be79b99 100644 --- a/types/headers.ts +++ b/types/headers.ts @@ -1,3 +1,20 @@ +/* + * Certain comments and type definitions are based off work from the official + * TypeScript project, for those the following attribution is applicable: + * + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED + * WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, + * MERCHANTABLITY OR NON-INFRINGEMENT. + * + * See the Apache Version 2.0 License for specific language governing permissions + * and limitations under the License. + */ // deno-lint-ignore-file no-var import type { OptionalKeys, RequiredKeys } from "./utils.ts"; @@ -15,7 +32,7 @@ type HeadersRecord = Record; // TODO: Add support for tuple format of headers export type TypedHeadersInit = T | Headers; -declare interface Headers { +export declare interface Headers { /** * Appends a new value onto an existing header inside a `Headers` object, or * adds the header if it does not already exist. @@ -46,13 +63,14 @@ declare interface Headers { */ set(name: K, value: NonNullable): void; - /** Returns an array containing the values of all `Set-Cookie` headers + /** + * Returns an array containing the values of all `Set-Cookie` headers * associated with a response. */ getSetCookie(): string[]; } -declare var Headers: { +export declare var Headers: { readonly prototype: Headers; new (init?: T): Headers; }; diff --git a/types/json.ts b/types/json.ts index 8ad3aa7..c4b7229 100644 --- a/types/json.ts +++ b/types/json.ts @@ -1,3 +1,20 @@ +/* + * Certain comments and type definitions are based off work from the official + * TypeScript project, for those the following attribution is applicable: + * + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED + * WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, + * MERCHANTABLITY OR NON-INFRINGEMENT. + * + * See the Apache Version 2.0 License for specific language governing permissions + * and limitations under the License. + */ // deno-lint-ignore-file no-explicit-any import type { Brand } from "./brand.ts"; diff --git a/types/url_search_params.ts b/types/url_search_params.ts new file mode 100644 index 0000000..f1e158c --- /dev/null +++ b/types/url_search_params.ts @@ -0,0 +1,202 @@ +/* + * Certain comments and type definitions are based off work from the official + * TypeScript project, for those the following attribution is applicable: + * + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED + * WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, + * MERCHANTABLITY OR NON-INFRINGEMENT. + * + * See the Apache Version 2.0 License for specific language governing permissions + * and limitations under the License. + */ +// deno-lint-ignore-file no-var + +import type { Brand } from "./brand.ts"; +import type { OptionalKeys, RequiredKeys } from "./utils.ts"; + +type URLSearchParamsRecord = Record; + +/** + * A {@link Brand branded} string containing the URLSearchParams stringified representation of {@link T}. + */ +export type URLSearchParamsString = Brand< + string, + T +>; + +// TODO: Add support for the iterable variant of the init parameter +export type URLSearchParamsInit = + | T + | URLSearchParamsString; + +export declare interface URLSearchParams< + T extends URLSearchParamsRecord = URLSearchParamsRecord, +> { + /** + * Appends a specified key/value pair as a new search parameter. + * + * ```ts + * let searchParams = new URLSearchParams(); + * searchParams.append('name', 'first'); + * searchParams.append('name', 'second'); + * ``` + */ + append(name: K, value: T[K]): void; + + /** + * Deletes search parameters that match a name, and optional value, + * from the list of all search parameters. + * + * ```ts + * let searchParams = new URLSearchParams([['name', 'value']]); + * searchParams.delete('name'); + * searchParams.delete('name', 'value'); + * ``` + */ + delete>(name: K, value?: T[K]): void; + + /** + * Returns all the values associated with a given search parameter + * as an array. + * + * ```ts + * searchParams.getAll('name'); + * ``` + */ + getAll>(name: K): [T[K]]; + getAll>(name: K): [] | [NonNullable]; + + /** + * Returns the first value associated to the given search parameter. + * + * ```ts + * searchParams.get('name'); + * ``` + */ + get(name: K): T[K]; + + /** + * Returns a boolean value indicating if a given parameter, + * or parameter and value pair, exists. + * + * ```ts + * searchParams.has('name'); + * searchParams.has('name', 'value'); + * ``` + */ + has>(name: K): true; + has>(name: K): boolean; + + /** + * Sets the value associated with a given search parameter to the + * given value. If there were several matching values, this method + * deletes the others. If the search parameter doesn't exist, this + * method creates it. + * + * ```ts + * searchParams.set('name', 'value'); + * ``` + */ + set(name: K, value: NonNullable): void; + + /** + * Calls a function for each element contained in this object in + * place and return undefined. Optionally accepts an object to use + * as this when executing callback as second argument. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * params.forEach((value, key, parent) => { + * console.log(value, key, parent); + * }); + * ``` + */ + forEach( + callbackfn: (value: T[keyof T], key: keyof T, parent: this) => void, + // deno-lint-ignore no-explicit-any + thisArg?: any, + ): void; + + /** + * Returns an iterator allowing to go through all keys contained + * in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const key of params.keys()) { + * console.log(key); + * } + * ``` + */ + keys(): IterableIterator; + + /** + * Returns an iterator allowing to go through all values contained + * in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const value of params.values()) { + * console.log(value); + * } + * ``` + */ + values(): IterableIterator; + + /** + * Returns an iterator allowing to go through all key/value + * pairs contained in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const [key, value] of params.entries()) { + * console.log(key, value); + * } + * ``` + */ + entries(): IterableIterator<[keyof T, T[keyof T]]>; + + /** + * Returns an iterator allowing to go through all key/value + * pairs contained in this object. + * + * ```ts + * const params = new URLSearchParams([["a", "b"], ["c", "d"]]); + * for (const [key, value] of params) { + * console.log(key, value); + * } + * ``` + */ + [Symbol.iterator](): IterableIterator<[keyof T, T[keyof T]]>; + + /** + * Returns a query string suitable for use in a URL. + * + * ```ts + * searchParams.toString(); + * ``` + */ + toString(): URLSearchParamsString; + + /** + * Contains the number of search parameters + * + * ```ts + * searchParams.size + * ``` + */ + size: number; +} + +export declare var URLSearchParams: { + readonly prototype: URLSearchParams; + new ( + init?: URLSearchParamsInit, + ): URLSearchParams; +}; diff --git a/types/urlsearchparams.ts b/types/url_search_params_string.ts similarity index 100% rename from types/urlsearchparams.ts rename to types/url_search_params_string.ts