diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 0efea76..5425467 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -2,7 +2,13 @@ "$schema": "./node_modules/oxfmt/configuration_schema.json", "useTabs": true, "printWidth": 80, - "ignorePatterns": ["**/dist", "**/build", "**/node_modules", "**/.claude"], + "ignorePatterns": [ + "**/dist", + "**/build", + "**/node_modules", + "**/.claude", + "__tests__/__snapshots__" + ], "sortPackageJson": { "sortScripts": true }, diff --git a/__tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts b/__tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts new file mode 100644 index 0000000..125522a --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-oneof-query-param-keeps-the-stringish-wire-types-in-every-branch/types.ts @@ -0,0 +1,12 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type ListThingsCommandQuery = { + size?: `${number}` | string; + flag?: "true" | "false" | string; + nested?: { + "count"?: `${number}` | string; + }; + }; +export type ListThingsCommandInput = ListThingsCommandQuery; +export type ListThingsCommandOutput = string | undefined; +export type InputListThingsCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts new file mode 100644 index 0000000..999d3a4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-anyof/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputOnlySchema = v.string(); +export const onlySchema = v.pipe(v.string(), v.trim()); +export const inputGetThingCommandResponseSchema = inputOnlySchema; +export const getThingCommandResponseSchema = onlySchema; diff --git a/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts new file mode 100644 index 0000000..999d3a4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-oneof/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputOnlySchema = v.string(); +export const onlySchema = v.pipe(v.string(), v.trim()); +export const inputGetThingCommandResponseSchema = inputOnlySchema; +export const getThingCommandResponseSchema = onlySchema; diff --git a/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts new file mode 100644 index 0000000..cc3c4da --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/a-single-member-combinator-emits-the-member-alone-two-member-oneof/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputOnlySchema = v.union([v.string(), v.number()]); +export const onlySchema = v.union([v.pipe(v.string(), v.trim()), v.number()]); +export const inputGetThingCommandResponseSchema = inputOnlySchema; +export const getThingCommandResponseSchema = onlySchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts new file mode 100644 index 0000000..131918b --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-alongside-properties-keeps-the-extra-keys-valid/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputConfigSchema = v.objectWithRest( + { + "name": v.optional(v.string()) + , + }, v.number()); +export const configSchema = v.objectWithRest( + { + "name": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }, v.number()); +export const inputGetThingCommandResponseSchema = inputConfigSchema; +export const getThingCommandResponseSchema = configSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts new file mode 100644 index 0000000..5c53bc7 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-empty-schema/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.looseObject( + { + "a": v.optional(v.string()) + , + }); +export const openSchema = v.looseObject( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts new file mode 100644 index 0000000..1c97502 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-false/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.strictObject( + { + "a": v.optional(v.string()) + , + }); +export const openSchema = v.strictObject( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts new file mode 100644 index 0000000..bb6c2da --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-string-schema/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.objectWithRest( + { + "a": v.optional(v.string()) + , + }, v.string()); +export const openSchema = v.objectWithRest( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }, v.pipe(v.string(), v.trim())); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts new file mode 100644 index 0000000..5c53bc7 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-chooses-the-object-schema-true/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.looseObject( + { + "a": v.optional(v.string()) + , + }); +export const openSchema = v.looseObject( + { + "a": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts new file mode 100644 index 0000000..d13b4c4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-true-accepts-any-key/valibot.ts @@ -0,0 +1,14 @@ +import * as v from "valibot"; + +export const inputOpenSchema = v.looseObject( + { + "name": v.optional(v.string()) + , + }); +export const openSchema = v.looseObject( + { + "name": v.exactOptional(v.pipe(v.string(), v.trim())) + , + }); +export const inputGetThingCommandResponseSchema = inputOpenSchema; +export const getThingCommandResponseSchema = openSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts new file mode 100644 index 0000000..5bbfb1d --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/types.ts @@ -0,0 +1,5 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type Labels = Record; +export type GetThingCommandInput = never; +export type InputGetThingCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts new file mode 100644 index 0000000..984fbbd --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/additionalproperties-types-the-record-value-instead-of-widening-to-unknown/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputLabelsSchema = v.record(v.string(), v.string()); +export const labelsSchema = inputLabelsSchema; +export const inputGetThingCommandResponseSchema = inputLabelsSchema; +export const getThingCommandResponseSchema = labelsSchema; diff --git a/__tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts b/__tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts new file mode 100644 index 0000000..dcab762 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/allinputs-union-carries-every-command-that-takes-an-input/main.ts @@ -0,0 +1,14 @@ +import { RestServiceClient, type RestServiceClientConfig } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { AlphaCommandOutput, BetaCommandOutput, GammaCommandOutput, WithBodyCommandInput, WithBodyCommandOutput, WithQueryCommandInput, WithQueryCommandOutput } from "./types.js"; + +export { ResponseValidationError } from "@block65/rest-client"; + +type AllInputs = UndefinedOnPartialDeep | UndefinedOnPartialDeep; +type AllOutputs = AlphaCommandOutput | BetaCommandOutput | GammaCommandOutput | WithBodyCommandOutput | WithQueryCommandOutput; + +export class TestRestClient extends RestServiceClient { + constructor(baseUrl: string | URL = new URL('https://api.example.com/'), config?: RestServiceClientConfig) { + super(baseUrl, config); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts new file mode 100644 index 0000000..fb77ee8 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/commands.ts @@ -0,0 +1,17 @@ +import { Command, stripUndefined, jsonStringify } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { PluginPullCommandQuery, PluginPullCommandInput, PluginPullCommandOutput } from "./types.js"; + + +/** + * PluginPullCommand + * + */ +export class PluginPullCommand extends Command, PluginPullCommandOutput, PluginPullCommandQuery> { + public override method = "post" as const; + + constructor(input: UndefinedOnPartialDeep) { + const {remote, body } = input; + super("/plugins/pull", jsonStringify(body), stripUndefined({remote})); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts new file mode 100644 index 0000000..81fac9e --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-array-request-body-with-parameters-stays-readable-as-both/types.ts @@ -0,0 +1,13 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type PluginPullCommandQuery = { + remote: string; + }; +type PluginPullCommandJsonBody = readonly (string)[]; +export type PluginPullCommandBody = PluginPullCommandJsonBody; +type PluginPullCommandBodyWrapper = { + body: PluginPullCommandJsonBody; + }; +export type PluginPullCommandInput = PluginPullCommandBodyWrapper & PluginPullCommandQuery; +export type PluginPullCommandOutput = string | undefined; +export type InputPluginPullCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts b/__tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts new file mode 100644 index 0000000..21cf87b --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-empty-properties-bag-is-a-record-not-an-empty-object-type/types.ts @@ -0,0 +1,5 @@ +import type { Jsonifiable, UndefinedOnPartialDeep } from "type-fest"; + +export type Empty = Record; +export type GetThingCommandInput = never; +export type InputGetThingCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts b/__tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts new file mode 100644 index 0000000..05c8bd3 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/an-operation-with-both-a-200-and-a-204-emits-one-output-type-argument/commands.ts @@ -0,0 +1,16 @@ +import { Command } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { SystemAuthCommandInput, SystemAuthCommandOutput } from "./types.js"; + + +/** + * SystemAuthCommand + * + */ +export class SystemAuthCommand extends Command, SystemAuthCommandOutput> { + public override method = "post" as const; + + constructor() { + super("/auth"); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts b/__tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts new file mode 100644 index 0000000..4b211f4 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/json-request-body-members-keep-their-real-json-types-nested-included/types.ts @@ -0,0 +1,15 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +type CreateContainerCommandJsonBody = { + "tty"?: boolean; + "retries"?: number; + "health"?: { + "interval"?: bigint; + "enabled"?: boolean; + }; + "sizes"?: readonly (number)[]; + }; +export type CreateContainerCommandBody = CreateContainerCommandJsonBody; +export type CreateContainerCommandInput = CreateContainerCommandJsonBody; +export type CreateContainerCommandOutput = string | undefined; +export type InputCreateContainerCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts b/__tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts new file mode 100644 index 0000000..bfb52e8 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/main-ts-emits-file-level-import-type-for-type-only-imports/main.ts @@ -0,0 +1,14 @@ +import { RestServiceClient, type RestServiceClientConfig } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { OneCommandInput, OneCommandOutput } from "./types.js"; + +export { ResponseValidationError } from "@block65/rest-client"; + +type AllInputs = UndefinedOnPartialDeep; +type AllOutputs = OneCommandOutput; + +export class TestRestClient extends RestServiceClient { + constructor(baseUrl: string | URL = new URL('https://api.example.com/'), config?: RestServiceClientConfig) { + super(baseUrl, config); + } +} diff --git a/__tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts b/__tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts new file mode 100644 index 0000000..4d8aea6 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/nested-query-param-members-get-the-same-stringish-treatment-as-top-level-ones/types.ts @@ -0,0 +1,15 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type SearchCommandQuery = { + limit?: `${number}`; + filter?: { + "age"?: `${number}`; + "big"?: `${bigint}`; + "active"?: "true" | "false"; + "label"?: string; + }; + ids?: readonly (`${number}`)[]; + }; +export type SearchCommandInput = SearchCommandQuery; +export type SearchCommandOutput = string | undefined; +export type InputSearchCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts b/__tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts new file mode 100644 index 0000000..4f971be --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/optional-query-params-do-not-carry-undefined-in-their-property-type/types.ts @@ -0,0 +1,9 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type ListFilesCommandQuery = { + purpose?: string; + limit?: `${number}`; + }; +export type ListFilesCommandInput = ListFilesCommandQuery; +export type ListFilesCommandOutput = string | undefined; +export type InputListFilesCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts b/__tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts new file mode 100644 index 0000000..dd36a71 --- /dev/null +++ b/__tests__/__snapshots__/codegen-regressions/the-generated-json-body-type-is-pascalcase/types.ts @@ -0,0 +1,9 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +type CreateBatchJsonBody = { + "input_file_id"?: string; + }; +export type CreateBatchCommandBody = CreateBatchJsonBody; +export type CreateBatchCommandInput = CreateBatchJsonBody; +export type CreateBatchCommandOutput = string | undefined; +export type InputCreateBatchCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap deleted file mode 100644 index 107a4f3..0000000 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ /dev/null @@ -1,218 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`const values > types 1`] = ` -"export type StringConst = "hello"; -export type NumberConst = 42; -export type BooleanConst = true; -export type NullConst = null; -" -`; - -exports[`const values > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputStringConstSchema = v.literal("hello"); -export const stringConstSchema = inputStringConstSchema; -export const inputNumberConstSchema = v.literal(42); -export const numberConstSchema = inputNumberConstSchema; -export const inputBooleanConstSchema = v.literal(true); -export const booleanConstSchema = inputBooleanConstSchema; -export const inputNullConstSchema = v.null(); -export const nullConstSchema = inputNullConstSchema; -" -`; - -exports[`header parameters > commands 1`] = ` -"import { Command } from "@block65/rest-client"; -import type { UndefinedOnPartialDeep } from "type-fest"; -import type { UploadDataCommandHeader, UploadDataCommandInput, UploadStatus } from "./types.js"; - -/** - * Tagged template literal that applies encodeURIComponent to all interpolated - * values, protecting path integrity from characters like \`/\` and \`#\`. - * @example encodePath\`/users/\${userId}\` // "/users/foo%2Fbar" - */ -function encodePath(strings: TemplateStringsArray, ...values: string[]) { - return String.raw({ raw: strings }, ...values.map((value) => encodeURIComponent(value))); -} - -/** - * UploadDataCommand - * - */ -export class UploadDataCommand extends Command, UploadStatus, never, UploadDataCommandHeader> { - public override method = "post" as const; - - constructor(input: UndefinedOnPartialDeep, headers: UploadDataCommandHeader) { - const {uploadId } = input; - super(encodePath\`/uploads/\${uploadId}\`, undefined, undefined, headers); - } -} -" -`; - -exports[`header parameters > commands-validated 1`] = ` -"import * as commands from "./commands.js"; -import * as schemas from "./valibot.js"; - -export class UploadDataCommand extends commands.UploadDataCommand { - static responseSchema = schemas.uploadDataCommandResponseSchema; -} -" -`; - -exports[`header parameters > hono 1`] = ` -"import { sValidator } from "@hono/standard-validator"; -import type { ValidationTargets } from "hono"; -import { PublicValidationError } from "@block65/rest-client"; -import { uploadDataCommandParamsSchema } from "./valibot.js"; - -type StandardSchema = Parameters[1]; - -// The query arrives here as Hono parsed it, and nothing below decodes -// it. A lone value for a repeated-key array stays a string, comma, -// space and pipe joined values stay joined, and a deepObject key stays -// bracketed, so each of those fails validation. A caller sending them -// decodes the query before this middleware or replaces it, reading the -// style and explode from the query spec each operation exports below. -function validate(target: TTarget, schema: TSchema) { - - return sValidator(target, schema, (result) => { - if (!result.success) { - throw PublicValidationError.fromIssues(result.error); - } - }); - -} - -export const uploadData = [ - validate("param", uploadDataCommandParamsSchema), - ] as const; -" -`; - -exports[`header parameters > types 1`] = ` -"import type { UndefinedOnPartialDeep } from "type-fest"; - -export type UploadStatus = "pending" | "complete"; -export type UploadDataCommandHeader = { - "content-type": "application/json" | "text/csv" | "application/xml"; - "content-length": \`\${bigint}\`; - "x-idempotency-key"?: string; - }; -export type UploadDataCommandParams = { - uploadId: string; - }; -export type UploadDataCommandInput = UploadDataCommandParams; -export type InputUploadDataCommandResponse = UndefinedOnPartialDeep; -" -`; - -exports[`header parameters > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputUploadStatusSchema = v.picklist(["pending", "complete"]); -export const uploadStatusSchema = inputUploadStatusSchema; -export const inputUploadDataCommandResponseSchema = inputUploadStatusSchema; -export const uploadDataCommandResponseSchema = uploadStatusSchema; -export const inputUploadDataCommandParamsSchema = v.strictObject({ - "uploadId": v.string() - }); -export const uploadDataCommandParamsSchema = v.strictObject({ - "uploadId": v.pipe(v.string(), v.trim()) - }); -export const inputUploadDataCommandHeaderSchema = v.object({ - "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), - "content-length": v.bigint(), - "x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid())) - }); -export const uploadDataCommandHeaderSchema = v.object({ - "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), - "content-length": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.bigint()), v.pipe(v.number(), v.integer(), v.toBigint(), v.bigint()), v.bigint()]), - "x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid())) - }); -" -`; - -exports[`nullables > types 1`] = ` -"export type MySchemaLolOrNullable = "lol" | "kek" | null; -" -`; - -exports[`oneOf with type null generates v.null() > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputNullableImageSchema = v.union([v.string(), v.null()]); -export const nullableImageSchema = v.union([v.pipe(v.string(), v.trim()), v.null()]); -" -`; - -exports[`query and header integer params coerce strings to numbers > types 1`] = ` -"import type { UndefinedOnPartialDeep } from "type-fest"; - -export type Dummy = string; -export type ExpireTime = bigint; -export type ListFilesCommandQuery = { - exp: \`\${bigint}\`; - limit?: \`\${number}\`; - }; -export type ListFilesCommandHeader = { - "x-rate-limit": \`\${number}\`; - }; -export type ListFilesCommandInput = ListFilesCommandQuery; -export type InputListFilesCommandResponse = UndefinedOnPartialDeep; -" -`; - -exports[`query and header integer params coerce strings to numbers > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputDummySchema = v.string(); -export const dummySchema = v.pipe(v.string(), v.trim()); -export const inputExpireTimeSchema = v.pipe(v.bigint(), v.minValue(0n)); -export const expireTimeSchema = v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]); -export const inputListFilesCommandResponseSchema = inputDummySchema; -export const listFilesCommandResponseSchema = dummySchema; -export const inputListFilesCommandQuerySchema = v.strictObject({ - "exp": v.pipe(v.bigint(), v.minValue(0n)), - "limit": v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))) - }); -export const listFilesCommandQuerySchema = v.strictObject({ - "exp": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]), - "limit": v.exactOptional(v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))])) - }); -export const inputListFilesCommandHeaderSchema = v.object({ - "x-rate-limit": v.pipe(v.number(), v.integer(), v.minValue(0)) - }); -export const listFilesCommandHeaderSchema = v.object({ - "x-rate-limit": v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(0))), v.pipe(v.number(), v.integer(), v.minValue(0))]) - }); -" -`; - -exports[`top-level type array with null > enums 1`] = ` -"export const nullableStringEnum = ["active", "inactive"] as const; -" -`; - -exports[`top-level type array with null > types 1`] = ` -"export type NullableString = string | null; -export type NullableStringEnum = "active" | "inactive" | null; -export type NullableInteger = number | null; -export type MultiType = string | number; -" -`; - -exports[`top-level type array with null > valibot 1`] = ` -"import * as v from "valibot"; - -export const inputNullableStringSchema = v.nullable(v.string()); -export const nullableStringSchema = inputNullableStringSchema; -export const inputNullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"])); -export const nullableStringEnumSchema = inputNullableStringEnumSchema; -export const inputNullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer())); -export const nullableIntegerSchema = inputNullableIntegerSchema; -export const inputMultiTypeSchema = v.union([v.string(), v.number()]); -export const multiTypeSchema = inputMultiTypeSchema; -" -`; diff --git a/__tests__/__snapshots__/nullables/const-values/types.ts b/__tests__/__snapshots__/nullables/const-values/types.ts new file mode 100644 index 0000000..92be38c --- /dev/null +++ b/__tests__/__snapshots__/nullables/const-values/types.ts @@ -0,0 +1,4 @@ +export type StringConst = "hello"; +export type NumberConst = 42; +export type BooleanConst = true; +export type NullConst = null; diff --git a/__tests__/__snapshots__/nullables/const-values/valibot.ts b/__tests__/__snapshots__/nullables/const-values/valibot.ts new file mode 100644 index 0000000..d880cb4 --- /dev/null +++ b/__tests__/__snapshots__/nullables/const-values/valibot.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; + +export const inputStringConstSchema = v.literal("hello"); +export const stringConstSchema = inputStringConstSchema; +export const inputNumberConstSchema = v.literal(42); +export const numberConstSchema = inputNumberConstSchema; +export const inputBooleanConstSchema = v.literal(true); +export const booleanConstSchema = inputBooleanConstSchema; +export const inputNullConstSchema = v.null(); +export const nullConstSchema = inputNullConstSchema; diff --git a/__tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts b/__tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts new file mode 100644 index 0000000..4b8cd8d --- /dev/null +++ b/__tests__/__snapshots__/nullables/enums-short-circuit-type-constraints-picklist-only/valibot.ts @@ -0,0 +1,6 @@ +import * as v from "valibot"; + +export const inputIntegerEnumSchema = v.picklist([0, 1, 2]); +export const integerEnumSchema = inputIntegerEnumSchema; +export const inputStringEnumSchema = v.picklist(["a@example.com", "b@example.com"]); +export const stringEnumSchema = inputStringEnumSchema; diff --git a/__tests__/__snapshots__/nullables/header-parameters/commands-validated.ts b/__tests__/__snapshots__/nullables/header-parameters/commands-validated.ts new file mode 100644 index 0000000..1ea8051 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/commands-validated.ts @@ -0,0 +1,6 @@ +import * as commands from "./commands.js"; +import * as schemas from "./valibot.js"; + +export class UploadDataCommand extends commands.UploadDataCommand { + static responseSchema = schemas.uploadDataCommandResponseSchema; +} diff --git a/__tests__/__snapshots__/nullables/header-parameters/commands.ts b/__tests__/__snapshots__/nullables/header-parameters/commands.ts new file mode 100644 index 0000000..3814d94 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/commands.ts @@ -0,0 +1,25 @@ +import { Command } from "@block65/rest-client"; +import type { UndefinedOnPartialDeep } from "type-fest"; +import type { UploadDataCommandHeader, UploadDataCommandInput, UploadStatus } from "./types.js"; + +/** + * Tagged template literal that applies encodeURIComponent to all interpolated + * values, protecting path integrity from characters like `/` and `#`. + * @example encodePath`/users/${userId}` // "/users/foo%2Fbar" + */ +function encodePath(strings: TemplateStringsArray, ...values: string[]) { + return String.raw({ raw: strings }, ...values.map((value) => encodeURIComponent(value))); +} + +/** + * UploadDataCommand + * + */ +export class UploadDataCommand extends Command, UploadStatus, never, UploadDataCommandHeader> { + public override method = "post" as const; + + constructor(input: UndefinedOnPartialDeep, headers: UploadDataCommandHeader) { + const {uploadId } = input; + super(encodePath`/uploads/${uploadId}`, undefined, undefined, headers); + } +} diff --git a/__tests__/__snapshots__/nullables/header-parameters/hono.ts b/__tests__/__snapshots__/nullables/header-parameters/hono.ts new file mode 100644 index 0000000..b4b5679 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/hono.ts @@ -0,0 +1,26 @@ +import { sValidator } from "@hono/standard-validator"; +import type { ValidationTargets } from "hono"; +import { PublicValidationError } from "@block65/rest-client"; +import { uploadDataCommandParamsSchema } from "./valibot.js"; + +type StandardSchema = Parameters[1]; + +// The query arrives here as Hono parsed it, and nothing below decodes +// it. A lone value for a repeated-key array stays a string, comma, +// space and pipe joined values stay joined, and a deepObject key stays +// bracketed, so each of those fails validation. A caller sending them +// decodes the query before this middleware or replaces it, reading the +// style and explode from the query spec each operation exports below. +function validate(target: TTarget, schema: TSchema) { + + return sValidator(target, schema, (result) => { + if (!result.success) { + throw PublicValidationError.fromIssues(result.error); + } + }); + +} + +export const uploadData = [ + validate("param", uploadDataCommandParamsSchema), + ] as const; diff --git a/__tests__/__snapshots__/nullables/header-parameters/types.ts b/__tests__/__snapshots__/nullables/header-parameters/types.ts new file mode 100644 index 0000000..fa46be2 --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/types.ts @@ -0,0 +1,13 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type UploadStatus = "pending" | "complete"; +export type UploadDataCommandHeader = { + "content-type": "application/json" | "text/csv" | "application/xml"; + "content-length": `${bigint}`; + "x-idempotency-key"?: string; + }; +export type UploadDataCommandParams = { + uploadId: string; + }; +export type UploadDataCommandInput = UploadDataCommandParams; +export type InputUploadDataCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/nullables/header-parameters/valibot.ts b/__tests__/__snapshots__/nullables/header-parameters/valibot.ts new file mode 100644 index 0000000..25ede4c --- /dev/null +++ b/__tests__/__snapshots__/nullables/header-parameters/valibot.ts @@ -0,0 +1,22 @@ +import * as v from "valibot"; + +export const inputUploadStatusSchema = v.picklist(["pending", "complete"]); +export const uploadStatusSchema = inputUploadStatusSchema; +export const inputUploadDataCommandResponseSchema = inputUploadStatusSchema; +export const uploadDataCommandResponseSchema = uploadStatusSchema; +export const inputUploadDataCommandParamsSchema = v.strictObject({ + "uploadId": v.string() + }); +export const uploadDataCommandParamsSchema = v.strictObject({ + "uploadId": v.pipe(v.string(), v.trim()) + }); +export const inputUploadDataCommandHeaderSchema = v.object({ + "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), + "content-length": v.bigint(), + "x-idempotency-key": v.optional(v.pipe(v.string(), v.uuid())) + }); +export const uploadDataCommandHeaderSchema = v.object({ + "content-type": v.picklist(["application/json", "text/csv", "application/xml"]), + "content-length": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.bigint()), v.pipe(v.number(), v.integer(), v.toBigint(), v.bigint()), v.bigint()]), + "x-idempotency-key": v.exactOptional(v.pipe(v.string(), v.uuid())) + }); diff --git a/__tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts b/__tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts new file mode 100644 index 0000000..25a2198 --- /dev/null +++ b/__tests__/__snapshots__/nullables/input-only-mode-omits-wire-schemas/valibot.ts @@ -0,0 +1,4 @@ +import * as v from "valibot"; + +export const inputNameSchema = v.pipe(v.string(), v.minLength(1)); +export const inputAmountSchema = v.pipe(v.bigint(), v.minValue(0n)); diff --git a/__tests__/__snapshots__/nullables/nullables/types.ts b/__tests__/__snapshots__/nullables/nullables/types.ts new file mode 100644 index 0000000..1689904 --- /dev/null +++ b/__tests__/__snapshots__/nullables/nullables/types.ts @@ -0,0 +1 @@ +export type MySchemaLolOrNullable = "lol" | "kek" | null; diff --git a/__tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts b/__tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts new file mode 100644 index 0000000..47d8bcb --- /dev/null +++ b/__tests__/__snapshots__/nullables/oneof-with-type-null-generates-v-null/valibot.ts @@ -0,0 +1,4 @@ +import * as v from "valibot"; + +export const inputNullableImageSchema = v.union([v.string(), v.null()]); +export const nullableImageSchema = v.union([v.pipe(v.string(), v.trim()), v.null()]); diff --git a/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts new file mode 100644 index 0000000..615a4a0 --- /dev/null +++ b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/types.ts @@ -0,0 +1,13 @@ +import type { UndefinedOnPartialDeep } from "type-fest"; + +export type Dummy = string; +export type ExpireTime = bigint; +export type ListFilesCommandQuery = { + exp: `${bigint}`; + limit?: `${number}`; + }; +export type ListFilesCommandHeader = { + "x-rate-limit": `${number}`; + }; +export type ListFilesCommandInput = ListFilesCommandQuery; +export type InputListFilesCommandResponse = UndefinedOnPartialDeep; diff --git a/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts new file mode 100644 index 0000000..bd36c75 --- /dev/null +++ b/__tests__/__snapshots__/nullables/query-and-header-integer-params-coerce-strings-to-numbers/valibot.ts @@ -0,0 +1,22 @@ +import * as v from "valibot"; + +export const inputDummySchema = v.string(); +export const dummySchema = v.pipe(v.string(), v.trim()); +export const inputExpireTimeSchema = v.pipe(v.bigint(), v.minValue(0n)); +export const expireTimeSchema = v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]); +export const inputListFilesCommandResponseSchema = inputDummySchema; +export const listFilesCommandResponseSchema = dummySchema; +export const inputListFilesCommandQuerySchema = v.strictObject({ + "exp": v.pipe(v.bigint(), v.minValue(0n)), + "limit": v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))) + }); +export const listFilesCommandQuerySchema = v.strictObject({ + "exp": v.union([v.pipe(v.string(), v.decimal(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.number(), v.integer(), v.toBigint(), v.pipe(v.bigint(), v.minValue(0n))), v.pipe(v.bigint(), v.minValue(0n))]), + "limit": v.exactOptional(v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))), v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))])) + }); +export const inputListFilesCommandHeaderSchema = v.object({ + "x-rate-limit": v.pipe(v.number(), v.integer(), v.minValue(0)) + }); +export const listFilesCommandHeaderSchema = v.object({ + "x-rate-limit": v.union([v.pipe(v.string(), v.decimal(), v.toNumber(), v.pipe(v.number(), v.integer(), v.minValue(0))), v.pipe(v.number(), v.integer(), v.minValue(0))]) + }); diff --git a/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts new file mode 100644 index 0000000..5d23cac --- /dev/null +++ b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/types.ts @@ -0,0 +1,4 @@ +export type MyDate = `${number}-${number}-${number}`; +export type MyTime = `${number}:${number}:${number}${string}`; +export type MyDateTime = `${number}-${number}-${number}T${number}:${number}:${number}${string}`; +export type MyDuration = `P${string}`; diff --git a/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts new file mode 100644 index 0000000..2a8123d --- /dev/null +++ b/__tests__/__snapshots__/nullables/rfc-3339-temporal-formats/valibot.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; + +export const inputMyDateSchema = v.pipe(v.string(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), v.custom<`${number}-${number}-${number}`>(() => true)); +export const myDateSchema = v.pipe(v.string(), v.trim(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), v.custom<`${number}-${number}-${number}`>(() => true)); +export const inputMyTimeSchema = v.pipe(v.string(), v.regex(/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "time"), v.custom<`${number}:${number}:${number}${string}`>(() => true)); +export const myTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "time"), v.custom<`${number}:${number}:${number}${string}`>(() => true)); +export const inputMyDateTimeSchema = v.pipe(v.string(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time"), v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>(() => true)); +export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time"), v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>(() => true)); +export const inputMyDurationSchema = v.pipe(v.string(), v.regex(/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, "duration"), v.custom<`P${string}`>(() => true)); +export const myDurationSchema = v.pipe(v.string(), v.trim(), v.regex(/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, "duration"), v.custom<`P${string}`>(() => true)); diff --git a/__tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts new file mode 100644 index 0000000..a417b46 --- /dev/null +++ b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/enums.ts @@ -0,0 +1 @@ +export const nullableStringEnum = ["active", "inactive"] as const; diff --git a/__tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts new file mode 100644 index 0000000..b96214b --- /dev/null +++ b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/types.ts @@ -0,0 +1,4 @@ +export type NullableString = string | null; +export type NullableStringEnum = "active" | "inactive" | null; +export type NullableInteger = number | null; +export type MultiType = string | number; diff --git a/__tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts new file mode 100644 index 0000000..42e7b9e --- /dev/null +++ b/__tests__/__snapshots__/nullables/top-level-type-array-with-null/valibot.ts @@ -0,0 +1,10 @@ +import * as v from "valibot"; + +export const inputNullableStringSchema = v.nullable(v.string()); +export const nullableStringSchema = inputNullableStringSchema; +export const inputNullableStringEnumSchema = v.nullable(v.picklist(["active", "inactive"])); +export const nullableStringEnumSchema = inputNullableStringEnumSchema; +export const inputNullableIntegerSchema = v.nullable(v.pipe(v.number(), v.integer())); +export const nullableIntegerSchema = inputNullableIntegerSchema; +export const inputMultiTypeSchema = v.union([v.string(), v.number()]); +export const multiTypeSchema = inputMultiTypeSchema; diff --git a/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts new file mode 100644 index 0000000..b1ee74b --- /dev/null +++ b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-anyof-branches/types.ts @@ -0,0 +1 @@ +export type MaybeUrl = AbsoluteUrl | RelativeUrl; diff --git a/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts new file mode 100644 index 0000000..194ccc0 --- /dev/null +++ b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-honored-inside-oneof-branches/types.ts @@ -0,0 +1 @@ +export type EventSource = "native" | EmbedUrl | SyndicatedUrl; diff --git a/__tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts new file mode 100644 index 0000000..a00244e --- /dev/null +++ b/__tests__/__snapshots__/typescript-hint/x-typescript-hint-on-top-level-string-schema/types.ts @@ -0,0 +1 @@ +export type EmbedUrl = `https://embed.example.com/${string}`; diff --git a/__tests__/codegen-regressions.test.ts b/__tests__/codegen-regressions.test.ts index 034e90d..d090143 100644 --- a/__tests__/codegen-regressions.test.ts +++ b/__tests__/codegen-regressions.test.ts @@ -1,6 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { assert, expect, test } from "vitest"; +import { test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +import { expectGenerated } from "./generated-snapshot.ts"; const respOk = { "200": { @@ -34,18 +35,8 @@ test("main.ts emits file-level `import type` for type-only imports", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const mainText = result.mainFile.getText(); - expect(mainText).toMatch( - /import type \{[^}]*OneCommandInput[^}]*\}\s+from\s+"\.\/types\.js"/, - ); - expect(mainText).toMatch( - /import type \{[^}]*UndefinedOnPartialDeep[^}]*\}\s+from\s+"type-fest"/, - ); - expect(mainText).not.toMatch(/import \{[^}]*type\s+OneCommandInput/); - expect(mainText).not.toMatch( - /import \{[^}]*type\s+UndefinedOnPartialDeep[^}]*\}\s+from\s+"type-fest"/, - ); + await expectGenerated([result.mainFile]); }); test("optional query params do not carry `| undefined` in their property type", async () => { @@ -77,15 +68,8 @@ test("optional query params do not carry `| undefined` in their property type", }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const typesText = result.typesFile.getText(); - const queryBlock = typesText.match( - /export type ListFilesCommandQuery = \{[\s\S]*?\};/, - )?.[0]; - assert.isDefined(queryBlock, "ListFilesCommandQuery"); - - expect(queryBlock).toContain("purpose?: string"); - expect(queryBlock).toContain("limit?: `${number}`"); - expect(queryBlock).not.toContain("undefined"); + + await expectGenerated([result.typesFile]); }); test("AllInputs union carries every command that takes an input", async () => { @@ -136,113 +120,50 @@ test("AllInputs union carries every command that takes an input", async () => { }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const mainText = result.mainFile.getText(); - const commandsText = result.commandsFile.getText(); - const typesText = result.typesFile.getText(); - - const allInputsBlock = mainText.match(/type AllInputs =[\s\S]*?;/)?.[0]; - assert.isDefined(allInputsBlock, "AllInputs"); - - const commandNames = [ - ...commandsText.matchAll(/^export class (\w+Command) extends Command { - assert.isDefined(name, "command name"); - - return name; - }); - - expect(commandNames.length).toBeGreaterThan(0); - - // a command declaring only responses gets an input of `never` - const hasNeverInput = (name: string) => - typesText.includes(`export type ${name}Input = never;`); - const missing = commandNames - .filter((name) => !hasNeverInput(name)) - .filter((name) => !allInputsBlock.includes(`${name}Input`)); - - expect(missing).toEqual([]); - - // `never` adds nothing to the union, and the three empty paths above put - // it on these three commands - const emptyCommands = commandNames.filter((name) => hasNeverInput(name)); - - expect(emptyCommands.toSorted()).toEqual([ - "AlphaCommand", - "BetaCommand", - "GammaCommand", - ]); - - const carried = emptyCommands.filter((name) => - allInputsBlock.includes(`${name}Input`), - ); - - expect(carried).toEqual([]); + await expectGenerated([result.mainFile]); }); // An empty schema permits any value, so the keys outside `properties` are // unconstrained and the object is loose -test("additionalProperties chooses the object schema", async () => { - const cases = [ - [{}, "v.looseObject("], - [{ type: "string" }, "v.objectWithRest("], - [false, "v.strictObject("], - [true, "v.looseObject("], - ] as const; - - const emitted = await Promise.all( - cases.map(async ([additionalProperties]) => { - const result = await processOpenApiDocument( - "/tmp/additional-properties", - docWithSchema("Open", { - type: "object", - properties: { a: { type: "string" } }, - additionalProperties, - }), - ); - - const text = result.valibotFile.getText(); - - return text.slice(text.indexOf("export const openSchema"), -1); - }), - ); +test.for([ + ["empty schema", {}], + ["string schema", { type: "string" }], + ["false", false], + ["true", true], +] as const)( + "additionalProperties chooses the object schema: %s", + async ([, additionalProperties]) => { + const result = await processOpenApiDocument( + "/tmp/additional-properties", + docWithSchema("Open", { + type: "object", + properties: { a: { type: "string" } }, + additionalProperties, + }), + ); - for (const [index, [, expected]] of cases.entries()) { - expect(emitted[index]).toContain(expected); - } -}); + await expectGenerated([result.valibotFile]); + }, +); // A one-member `anyOf` or `oneOf` is that member. `v.union` of one option // only wraps its issues, and the block65 valibot rules reject it -test("a single-member combinator emits the member alone", async () => { - const cases: [oas31.SchemaObject, string][] = [ - [{ anyOf: [{ type: "string" }] }, "v.string()"], - [{ oneOf: [{ type: "string" }] }, "v.string()"], - [ - { oneOf: [{ type: "string" }, { type: "number" }] }, - "v.union([v.string(), v.number()])", - ], - ]; - - const emitted = await Promise.all( - cases.map(async ([schema]) => { - const result = await processOpenApiDocument( - "/tmp/single-member-combinator", - docWithSchema("Only", schema), - ); - - const text = result.valibotFile.getText(); - - return text.slice(text.indexOf("export const inputOnlySchema"), -1); - }), - ); - - for (const [index, [, expected]] of cases.entries()) { - expect(emitted[index]).toContain( - `export const inputOnlySchema = ${expected};`, +test.for<[string, oas31.SchemaObject]>([ + ["anyOf", { anyOf: [{ type: "string" }] }], + ["oneOf", { oneOf: [{ type: "string" }] }], + ["two-member oneOf", { oneOf: [{ type: "string" }, { type: "number" }] }], +])( + "a single-member combinator emits the member alone: %s", + async ([, schema]) => { + const result = await processOpenApiDocument( + "/tmp/single-member-combinator", + docWithSchema("Only", schema), ); - } -}); + + await expectGenerated([result.valibotFile]); + }, +); function docWithSchema(name: string, schema: oas31.SchemaObject) { return { @@ -278,12 +199,7 @@ test("additionalProperties types the record value instead of widening to unknown }), ); - expect(result.typesFile.getText()).toContain( - "Record", - ); - expect(result.valibotFile.getText()).toContain( - "v.record(v.string(), v.string())", - ); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("additionalProperties alongside properties keeps the extra keys valid", async () => { @@ -296,10 +212,7 @@ test("additionalProperties alongside properties keeps the extra keys valid", asy }), ); - const valibotText = result.valibotFile.getText(); - - expect(valibotText).toContain("v.objectWithRest("); - expect(valibotText).not.toMatch(/v\.strictObject\(\{\s*name:/); + await expectGenerated([result.valibotFile]); }); test("additionalProperties true accepts any key", async () => { @@ -312,7 +225,7 @@ test("additionalProperties true accepts any key", async () => { }), ); - expect(result.valibotFile.getText()).toContain("v.looseObject("); + await expectGenerated([result.valibotFile]); }); test("an empty properties bag is a record, not an empty object type", async () => { @@ -321,10 +234,7 @@ test("an empty properties bag is a record, not an empty object type", async () = docWithSchema("Empty", { type: "object", properties: {} }), ); - const typesText = result.typesFile.getText(); - - expect(typesText).toContain("Record"); - expect(typesText).not.toMatch(/=\s*\{\};/); + await expectGenerated([result.typesFile]); }); test("the generated JSON body type is PascalCase", async () => { @@ -353,7 +263,7 @@ test("the generated JSON body type is PascalCase", async () => { const result = await processOpenApiDocument("/tmp/whatever", schema); - expect(result.typesFile.getText()).toContain("type CreateBatchJsonBody"); + await expectGenerated([result.typesFile]); }); test("an operation with both a 200 and a 204 emits one output type argument", async () => { @@ -384,15 +294,8 @@ test("an operation with both a 200 and a 204 emits one output type argument", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const extendsClause = - result.commandsFile - .getClass("SystemAuthCommand") - ?.getExtends() - ?.getTypeArguments() - .map((arg) => arg.getText()) ?? []; - - expect(extendsClause).not.toContain("undefined"); - expect(extendsClause.length).toBeLessThanOrEqual(2); + + await expectGenerated([result.commandsFile]); }); test("an array request body with parameters stays readable as both", async () => { @@ -425,16 +328,8 @@ test("an array request body with parameters stays readable as both", async () => }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const inputBlock = result.typesFile - .getTypeAlias("PluginPullCommandInput") - ?.getTypeNode() - ?.getText(); - assert.isDefined(inputBlock, "PluginPullCommandInput"); - - expect(inputBlock).toContain("PluginPullCommandBodyWrapper"); - expect(result.commandsFile.getText()).toMatch( - /const \{\s*remote,\s*body\s*\} = input/, - ); + + await expectGenerated([result.typesFile, result.commandsFile]); }); test("nested query param members get the same stringish treatment as top-level ones", async () => { @@ -480,19 +375,8 @@ test("nested query param members get the same stringish treatment as top-level o }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = result.typesFile - .getTypeAlias("SearchCommandQuery") - ?.getTypeNode() - ?.getText(); - assert.isDefined(queryBlock, "SearchCommandQuery"); - - expect(queryBlock).toMatch(/limit\?: `\$\{number\}`/); - expect(queryBlock).toMatch(/"age"\?: `\$\{number\}`/); - expect(queryBlock).toMatch(/"big"\?: `\$\{bigint\}`/); - expect(queryBlock).toMatch(/"active"\?: "true" \| "false"/); - expect(queryBlock).toMatch(/"label"\?: string/); - expect(queryBlock).toMatch(/ids\?: readonly \(`\$\{number\}`\)\[\]/); - expect(queryBlock).not.toMatch(/"age"\?: number/); + + await expectGenerated([result.typesFile]); }); test("json request body members keep their real JSON types, nested included", async () => { @@ -531,21 +415,12 @@ test("json request body members keep their real JSON types, nested included", as }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const bodyBlock = result.typesFile - .getTypeAlias("CreateContainerCommandJsonBody") - ?.getTypeNode() - ?.getText(); - assert.isDefined(bodyBlock, "CreateContainerCommandJsonBody"); - - expect(bodyBlock).toMatch(/"tty"\?: boolean/); - expect(bodyBlock).toMatch(/"retries"\?: number/); - expect(bodyBlock).toMatch(/"interval"\?: bigint/); - expect(bodyBlock).toMatch(/"enabled"\?: boolean/); - expect(bodyBlock).toMatch(/"sizes"\?: readonly \(number\)\[\]/); - expect(bodyBlock).not.toContain('"true" | "false"'); - expect(bodyBlock).not.toContain("`${number}`"); + + await expectGenerated([result.typesFile]); }); +// Composition has to forward the codegen options the same way the array and +// object branches do, or a oneOf collapses back to the JSON types test("a oneOf query param keeps the stringish wire types in every branch", async () => { const schema: oas31.OpenAPIObject = { openapi: "3.1.0", @@ -592,17 +467,6 @@ test("a oneOf query param keeps the stringish wire types in every branch", async }; const result = await processOpenApiDocument("/tmp/whatever", schema); - const queryBlock = result.typesFile - .getTypeAlias("ListThingsCommandQuery") - ?.getTypeNode() - ?.getText(); - assert.isDefined(queryBlock, "ListThingsCommandQuery"); - - // Composition has to forward the codegen options the same way the array - // and object branches do, or a oneOf collapses back to the JSON types - expect(queryBlock).toContain("`${number}`"); - expect(queryBlock).toContain('"true" | "false"'); - expect(queryBlock).not.toMatch(/size\?: number/); - expect(queryBlock).not.toMatch(/flag\?: boolean/); - expect(queryBlock).not.toMatch(/"count"\?: number/); + + await expectGenerated([result.typesFile]); }); diff --git a/__tests__/generated-snapshot.ts b/__tests__/generated-snapshot.ts new file mode 100644 index 0000000..2d7d997 --- /dev/null +++ b/__tests__/generated-snapshot.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { expect } from "vitest"; + +type GeneratedFile = { getBaseName(): string; getText(): string }; + +function slug(text: string) { + return text + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-|-$/g, ""); +} + +/** + * Snapshots each file into a folder named for the current test, so a generator + * change reviews as a diff of the code it emits + */ +export async function expectGenerated(files: GeneratedFile[]) { + const { currentTestName, testPath } = expect.getState(); + const folder = `${path.basename(testPath ?? "unknown", ".test.ts")}/${slug(currentTestName ?? "unknown")}`; + + await Promise.all( + files.map((file) => + expect(file.getText()).toMatchFileSnapshot( + `__snapshots__/${folder}/${file.getBaseName()}`, + ), + ), + ); +} diff --git a/__tests__/nullables.test.ts b/__tests__/nullables.test.ts index 6c3311e..9fff29f 100644 --- a/__tests__/nullables.test.ts +++ b/__tests__/nullables.test.ts @@ -1,6 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { expect, test } from "vitest"; +import { test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +import { expectGenerated } from "./generated-snapshot.ts"; test("nullables", async () => { const result = await processOpenApiDocument( @@ -28,7 +29,7 @@ test("nullables", async () => { [], ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); + await expectGenerated([result.typesFile]); }); test("top-level type array with null", async () => { @@ -62,9 +63,11 @@ test("top-level type array with null", async () => { [], ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.valibotFile?.getText()).toMatchSnapshot("valibot"); - expect(result.enumsFile.getText()).toMatchSnapshot("enums"); + await expectGenerated([ + result.typesFile, + result.valibotFile, + result.enumsFile, + ]); }); test("const values", async () => { @@ -100,8 +103,7 @@ test("const values", async () => { [], ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.valibotFile?.getText()).toMatchSnapshot("valibot"); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("RFC 3339 temporal formats", async () => { @@ -119,31 +121,7 @@ test("RFC 3339 temporal formats", async () => { }, }); - const types = result.typesFile.getText(); - - // Template-literal string types capture the digit shape for every format - expect(types).toContain( - "export type MyDate = `${number}-${number}-${number}`", - ); - expect(types).toContain( - "export type MyTime = `${number}:${number}:${number}${string}`", - ); - expect(types).toContain( - "export type MyDateTime = `${number}-${number}-${number}T${number}:${number}:${number}${string}`", - ); - expect(types).toContain("export type MyDuration = `P${string}`"); - - const valibot = result.valibotFile.getText(); - - // Each format gets a runtime regex plus a v.custom<...> type hint, in both - // the input and wire schema (8 of each across the four formats) - expect(valibot.match(/v\.regex\(/g)?.length).toBe(8); - expect(valibot.match(/v\.custom(() => true)", - ); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("enums short-circuit type constraints (picklist only)", async () => { @@ -171,18 +149,7 @@ test("enums short-circuit type constraints (picklist only)", async () => { }, }); - const valibot = result.valibotFile.getText(); - expect(valibot).toContain( - "export const inputIntegerEnumSchema = v.picklist([0, 1, 2])", - ); - expect(valibot).toContain( - 'export const inputStringEnumSchema = v.picklist(["a@example.com", "b@example.com"])', - ); - - // No leftover type-specific constraints leaked onto the enums - expect(valibot).not.toContain("v.minValue"); - expect(valibot).not.toContain("v.minLength"); - expect(valibot).not.toContain("v.email"); + await expectGenerated([result.valibotFile]); }); test("oneOf with type null generates v.null()", async () => { @@ -199,7 +166,7 @@ test("oneOf with type null generates v.null()", async () => { }, }); - expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); + await expectGenerated([result.valibotFile]); }); test("query and header integer params coerce strings to numbers", async () => { @@ -274,8 +241,7 @@ test("query and header integer params coerce strings to numbers", async () => { schema, ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); + await expectGenerated([result.typesFile, result.valibotFile]); }); test("header parameters", async () => { @@ -354,13 +320,13 @@ test("header parameters", async () => { schema, ); - expect(result.typesFile.getText()).toMatchSnapshot("types"); - expect(result.commandsFile.getText()).toMatchSnapshot("commands"); - expect(result.commandsValidatedFile.getText()).toMatchSnapshot( - "commands-validated", - ); - expect(result.valibotFile.getText()).toMatchSnapshot("valibot"); - expect(result.honoFile.getText()).toMatchSnapshot("hono"); + await expectGenerated([ + result.typesFile, + result.commandsFile, + result.commandsValidatedFile, + result.valibotFile, + result.honoFile, + ]); }); test("input-only mode omits wire schemas", async () => { @@ -412,14 +378,5 @@ test("input-only mode omits wire schemas", async () => { { inputOnly: true }, ); - const valibotText = result.valibotFile.getText(); - - expect(valibotText).toContain("inputNameSchema"); - expect(valibotText).toContain("inputAmountSchema"); - expect(valibotText).not.toMatch(/export const nameSchema\b/); - expect(valibotText).not.toMatch(/export const amountSchema\b/); - expect(valibotText).not.toContain("v.trim()"); - expect(valibotText).not.toContain("v.toNumber()"); - expect(valibotText).not.toContain("v.toBigint()"); - expect(valibotText).not.toMatch(/export const listItemsCommandQuerySchema\b/); + await expectGenerated([result.valibotFile]); }); diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 042c135..f1dc771 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["../bin", "../lib", "."] + "include": ["../bin", "../lib", "."], + "exclude": ["__snapshots__", "node_modules"] } diff --git a/__tests__/typescript-hint.test.ts b/__tests__/typescript-hint.test.ts index 3aa055a..9033bc6 100644 --- a/__tests__/typescript-hint.test.ts +++ b/__tests__/typescript-hint.test.ts @@ -1,6 +1,7 @@ import type { oas31 } from "openapi3-ts"; -import { expect, test } from "vitest"; +import { test } from "vitest"; import { processOpenApiDocument } from "../lib/process-document.ts"; +import { expectGenerated } from "./generated-snapshot.ts"; const baseDoc = { openapi: "3.1.0" as const, @@ -26,12 +27,7 @@ test("x-typescript-hint on top-level string schema", async () => { schema, ); - const typesText = result.typesFile.getText(); - - expect(typesText).toContain( - "export type EmbedUrl = `https://embed.example.com/${string}`;", - ); - expect(typesText).not.toMatch(/export type EmbedUrl = string;/); + await expectGenerated([result.typesFile]); }); test("x-typescript-hint honored inside oneOf branches", async () => { @@ -55,12 +51,7 @@ test("x-typescript-hint honored inside oneOf branches", async () => { schema, ); - const typesText = result.typesFile.getText(); - - expect(typesText).toMatch( - /export type EventSource =\s*"native" \| EmbedUrl \| SyndicatedUrl;/, - ); - expect(typesText).not.toMatch(/EventSource = "native" \| string/); + await expectGenerated([result.typesFile]); }); test("x-typescript-hint honored inside anyOf branches", async () => { @@ -83,7 +74,5 @@ test("x-typescript-hint honored inside anyOf branches", async () => { schema, ); - expect(result.typesFile.getText()).toMatch( - /export type MaybeUrl =\s*AbsoluteUrl \| RelativeUrl;/, - ); + await expectGenerated([result.typesFile]); }); diff --git a/oxlint.config.ts b/oxlint.config.ts index dea004e..a2cc905 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from "@block65/shared-config/oxlint"; export default defineConfig({ + // generated code under test, compared byte for byte + ignorePatterns: ["__tests__/__snapshots__/**"], + // a consumer's config enables the valibot group, so the fixtures lint under it groups: { vitest: "on", valibot: "on" },