From 4dc9b424fbe0681053bb4c3e0d6b79a89e157719 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Tue, 28 Jan 2025 13:45:07 -0500 Subject: [PATCH 1/6] unstash --- .../src/common/scalar.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/http-server-javascript/src/common/scalar.ts b/packages/http-server-javascript/src/common/scalar.ts index 4948be6d0b..8add28a6e7 100644 --- a/packages/http-server-javascript/src/common/scalar.ts +++ b/packages/http-server-javascript/src/common/scalar.ts @@ -8,6 +8,54 @@ import { parseCase } from "../util/case.js"; import { UnimplementedError } from "../util/error.js"; import { getFullyQualifiedTypeName } from "../util/name.js"; +export interface ScalarInfo< + Encodings extends { [target: string]: { [encoding: string]: ScalarEncoding } } = {}, +> { + /** + * The TypeScript type that represents the scalar, or an Importable if the scalar requires a representation + * that is not built-in. + */ + type: string | Importable; + + /** + * A map of supported encodings for the scalar. + */ + encodings?: Encodings; + + /** + * A map of default encodings for the scalar. + */ + defaultEncodings?: { + [contentType: string]: [target: string, encoding: string]; + }; +} + +export interface Importable { + (ctx: JsContext): Promise; +} + +export interface ScalarEncoding { + encodeTemplate: string; + decodeTemplate: string; +} + +const SCALARS = new Map([ + [ + "TypeSpec.bytes", + { + type: "Uint8Array", + encodings: { + "TypeSpec.string": { + base64: { + encodeTemplate: "({} instanceof Buffer ? {} : Buffer.from({})).toString('base64')", + decodeTemplate: "Buffer.from({}, 'base64')", + }, + }, + }, + }, + ], +]); + /** * Emits a declaration for a scalar type. * From 706204fc49f5e98cae1d093264dd011d7111546e Mon Sep 17 00:00:00 2001 From: Will Temple Date: Thu, 30 Jan 2025 10:51:14 -0500 Subject: [PATCH 2/6] [hsj] Implement scalar encode/decode support. --- .../generated-defs/helpers/datetime.ts | 173 +++++ .../generated-defs/helpers/index.ts | 1 + .../src/common/declaration.ts | 2 +- .../src/common/reference.ts | 2 +- .../src/common/scalar.ts | 629 ++++++++++++++---- .../src/common/serialization/index.ts | 15 +- .../src/common/serialization/json.ts | 223 +++++-- .../src/helpers/datetime.ts | 145 ++++ .../src/http/server/index.ts | 44 +- .../src/http/server/multipart.ts | 2 +- packages/http-server-javascript/src/lib.ts | 6 + .../http-server-javascript/src/util/case.ts | 19 + .../src/util/differentiate.ts | 23 +- .../test/datetime.test.ts | 184 +++++ .../test/scalar.test.ts | 266 ++++++++ 15 files changed, 1534 insertions(+), 200 deletions(-) create mode 100644 packages/http-server-javascript/generated-defs/helpers/datetime.ts create mode 100644 packages/http-server-javascript/src/helpers/datetime.ts create mode 100644 packages/http-server-javascript/test/datetime.test.ts create mode 100644 packages/http-server-javascript/test/scalar.test.ts diff --git a/packages/http-server-javascript/generated-defs/helpers/datetime.ts b/packages/http-server-javascript/generated-defs/helpers/datetime.ts new file mode 100644 index 0000000000..1ab75bd37a --- /dev/null +++ b/packages/http-server-javascript/generated-defs/helpers/datetime.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Module } from "../../src/ctx.js"; + +export let module: Module = undefined as any; + +// prettier-ignore +const lines = [ + "// Copyright (c) Microsoft Corporation", + "// Licensed under the MIT license.", + "", + "// #region Duration", + "", + "/**", + " * Regular expression for matching ISO8601 duration strings.", + " *", + " * Yields:", + " * - 0: the full match", + " * - 1: the sign (optional)", + " * - 2: years (optional)", + " * - 3: months (optional)", + " * - 4: weeks (optional)", + " * - 5: days (optional)", + " * - 6: hours (optional)", + " * - 7: minutes (optional)", + " * - 8: seconds (optional)", + " */", + "const ISO8601_DURATION_REGEX =", + " /^(-)?P(?:((?:\\d*[.,])?\\d+)Y)?(?:((?:\\d*[.,])?\\d+)M)?(?:((?:\\d*[.,])?\\d+)W)?(?:((?:\\d*[.,])?\\d+)D)?(?:T(?:((?:\\d*[.,])?\\d+)H)?(?:((?:\\d*[.,])?\\d+)M)?(?:((?:\\d*[.,])?\\d+)S)?)?$/;", + "", + "/**", + " * A duration of time, measured in years, months, weeks, days, hours, minutes, and seconds.", + " *", + " * The values may be fractional and are not normalized (e.g. 36 hours is not the same duration as 1 day and 12 hours", + " * when accounting for Daylight Saving Time changes or leap seconds).", + " *", + " * @see https://en.wikipedia.org/wiki/ISO_8601#Durations", + " */", + "export interface Duration {", + " /**", + " * \"+\" if the duration is positive, \"-\" if the duration is negative.", + " */", + " sign: \"+\" | \"-\";", + " /**", + " * The number of years in the duration.", + " */", + " years: number;", + " /**", + " * The number of months in the duration.", + " */", + " months: number;", + " /**", + " * The number of weeks in the duration.", + " */", + " weeks: number;", + " /**", + " * The number of days in the duration.", + " */", + " days: number;", + " /**", + " * The number of hours in the duration.", + " */", + " hours: number;", + " /**", + " * The number of minutes in the duration.", + " */", + " minutes: number;", + " /**", + " * The number of seconds in the duration.", + " */", + " seconds: number;", + "}", + "", + "export const Duration = Object.freeze({", + " /**", + " * Parses an ISO8601 duration string into an object.", + " *", + " * @see https://en.wikipedia.org/wiki/ISO_8601#Durations", + " *", + " * @param duration - the duration string to parse", + " * @returns an object containing the parsed duration", + " */", + " parseISO8601(duration: string, maxLength: number = 100): Duration {", + " duration = duration.trim();", + " if (duration.length > maxLength)", + " throw new Error(`ISO8601 duration string is too long: ${duration}`);", + "", + " const match = duration.match(ISO8601_DURATION_REGEX);", + "", + " if (!match) throw new Error(`Invalid ISO8601 duration: ${duration}`);", + "", + " return {", + " sign: match[1] === undefined ? \"+\" : (match[1] as Duration[\"sign\"]),", + " years: parseFloatNormal(match[2]),", + " months: parseFloatNormal(match[3]),", + " weeks: parseFloatNormal(match[4]),", + " days: parseFloatNormal(match[5]),", + " hours: parseFloatNormal(match[6]),", + " minutes: parseFloatNormal(match[7]),", + " seconds: parseFloatNormal(match[8]),", + " };", + "", + " function parseFloatNormal(match: string | undefined): number {", + " if (match === undefined) return 0;", + "", + " const normalized = match.replace(\",\", \".\");", + "", + " const parsed = parseFloat(normalized);", + "", + " if (isNaN(parsed))", + " throw new Error(`Unreachable: Invalid number in ISO8601 duration string: ${match}`);", + "", + " return parsed;", + " }", + " },", + " /**", + " * Writes a Duration to an ISO8601 duration string.", + " *", + " * @see https://en.wikipedia.org/wiki/ISO_8601#Durations", + " *", + " * @param duration - the duration to write to a string", + " * @returns a string in ISO8601 duration format", + " */", + " toISO8601(duration: Duration): string {", + " const sign = duration.sign === \"+\" ? \"\" : \"-\";", + "", + " const years =", + " duration.years !== 0 && !isNaN(Number(duration.years)) ? `${duration.years}Y` : \"\";", + " const months =", + " duration.months !== 0 && !isNaN(Number(duration.months)) ? `${duration.months}M` : \"\";", + " const weeks =", + " duration.weeks !== 0 && !isNaN(Number(duration.weeks)) ? `${duration.weeks}W` : \"\";", + " const days = duration.days !== 0 && !isNaN(Number(duration.days)) ? `${duration.days}D` : \"\";", + "", + " let time = \"\";", + "", + " const _hours = duration.hours !== 0 && !isNaN(Number(duration.hours));", + " const _minutes = duration.minutes !== 0 && !isNaN(Number(duration.minutes));", + " const _seconds = duration.seconds !== 0 && !isNaN(Number(duration.seconds));", + "", + " if (_hours || _minutes || _seconds) {", + " const hours = _hours ? `${duration.hours}H` : \"\";", + " const minutes = _minutes ? `${duration.minutes}M` : \"\";", + " const seconds = _seconds ? `${duration.seconds}S` : \"\";", + "", + " time = `T${hours}${minutes}${seconds}`;", + " }", + "", + " return `${sign}P${years}${months}${weeks}${days}${time}`;", + " },", + "});", + "", + "// #endregion", + "", +]; + +export async function createModule(parent: Module): Promise { + if (module) return module; + + module = { + name: "datetime", + cursor: parent.cursor.enter("datetime"), + imports: [], + declarations: [], + }; + + module.declarations.push(lines); + + parent.declarations.push(module); + + return module; +} diff --git a/packages/http-server-javascript/generated-defs/helpers/index.ts b/packages/http-server-javascript/generated-defs/helpers/index.ts index 1db2f0741a..1eb80330c2 100644 --- a/packages/http-server-javascript/generated-defs/helpers/index.ts +++ b/packages/http-server-javascript/generated-defs/helpers/index.ts @@ -16,6 +16,7 @@ export async function createModule(parent: Module): Promise { }; // Child modules + await import("./datetime.js").then((m) => m.createModule(module)); await import("./header.js").then((m) => m.createModule(module)); await import("./multipart.js").then((m) => m.createModule(module)); await import("./router.js").then((m) => m.createModule(module)); diff --git a/packages/http-server-javascript/src/common/declaration.ts b/packages/http-server-javascript/src/common/declaration.ts index cff3520108..2373ca4dda 100644 --- a/packages/http-server-javascript/src/common/declaration.ts +++ b/packages/http-server-javascript/src/common/declaration.ts @@ -42,7 +42,7 @@ export function* emitDeclaration( break; } case "Scalar": { - yield emitScalar(ctx, type); + yield emitScalar(ctx, type, module); break; } default: { diff --git a/packages/http-server-javascript/src/common/reference.ts b/packages/http-server-javascript/src/common/reference.ts index 26529855c7..49e2706a28 100644 --- a/packages/http-server-javascript/src/common/reference.ts +++ b/packages/http-server-javascript/src/common/reference.ts @@ -70,7 +70,7 @@ export function emitTypeReference( switch (type.kind) { case "Scalar": // Get the scalar and return it directly, as it is a primitive. - return getJsScalar(ctx.program, type, position); + return getJsScalar(ctx, module, type, position).type; case "Model": { // First handle arrays. if (isArrayModelType(ctx.program, type)) { diff --git a/packages/http-server-javascript/src/common/scalar.ts b/packages/http-server-javascript/src/common/scalar.ts index 5cc29e4fd5..2469584a61 100644 --- a/packages/http-server-javascript/src/common/scalar.ts +++ b/packages/http-server-javascript/src/common/scalar.ts @@ -1,44 +1,166 @@ // Copyright (c) Microsoft Corporation // Licensed under the MIT license. -import { DiagnosticTarget, NoTarget, Program, Scalar, formatDiagnostic } from "@typespec/compiler"; -import { JsContext } from "../ctx.js"; +import { DiagnosticTarget, NoTarget, Program, Scalar } from "@typespec/compiler"; +import { JsContext, Module } from "../ctx.js"; import { reportDiagnostic } from "../lib.js"; import { parseCase } from "../util/case.js"; -import { UnimplementedError } from "../util/error.js"; import { getFullyQualifiedTypeName } from "../util/name.js"; -export interface ScalarInfo< - Encodings extends { [target: string]: { [encoding: string]: ScalarEncoding } } = {}, -> { +import { HttpOperationParameter } from "@typespec/http"; +import { module as dateTimeModule } from "../../generated-defs/helpers/datetime.js"; +import { UnreachableError } from "../util/error.js"; + +export interface ScalarInfo { /** - * The TypeScript type that represents the scalar, or an Importable if the scalar requires a representation + * The TypeScript type that represents the scalar, or a function if the scalar requires a representation * that is not built-in. */ - type: string | Importable; + type: MaybeDependent; /** * A map of supported encodings for the scalar. */ - encodings?: Encodings; + encodings?: { + [target: string]: { + /** + * The default encoding for the target. + */ + default?: MaybeDependent; + + /** + * The encoding for the scalar when encoded using a particular method. + */ + [encoding: string]: MaybeDependent | undefined; + }; + }; /** * A map of default encodings for the scalar. */ defaultEncodings?: { - [contentType: string]: [target: string, encoding: string]; + /** + * The default encoding pair to use for a given MIME type. + */ + byMimeType?: { [contentType: string]: [string, string] }; + /** + * The default encoding pair to use in the context of HTTP metadata. + */ + http?: { + [K in HttpOperationParameter["type"]]?: [string, string]; + }; }; + + /** + * Whether or not this scalar can serve as a JSON-compatible type. + * + * If JSON serialization reaches a non-compatible scalar and no more encodings are available, it is treated as + * an unknown type. + */ + isJsonCompatible: boolean; } -export interface Importable { - (ctx: JsContext): Promise; +/** + * A function that resolves a value dependent on the context and module it's requested from. + */ +export interface Dependent { + (ctx: JsContext, module: Module): T; } +/** + * A value that might be dependent. + */ +export type MaybeDependent = T | Dependent; + +/** + * A definition of a scalar encoding. + */ export interface ScalarEncoding { - encodeTemplate: string; - decodeTemplate: string; + /** + * If set, the name of the encoding to use as a base for this encoding. + * + * This can be used to define an encoding that is a modification of another encoding, such as a URL-encoded version + * of a base64-encoded value, which depends on the base64 encoding. + */ + via?: string; + + /** + * The template to use to encode the scalar. + * + * The position of the string "{}" in the template will be replaced with the value to encode. + */ + encodeTemplate: MaybeDependent; + + /** + * The template to use to decode the scalar. + * + * The position of the string "{}" in the template will be replaced with the value to decode. + */ + decodeTemplate: MaybeDependent; } +const TYPESPEC_DURATION: ScalarInfo = { + type: function importDuration(_, module) { + module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); + + return "Duration"; + }, + encodings: { + "TypeSpec.string": { + default: { + via: "iso8601", + encodeTemplate: "{}", + decodeTemplate: "{}", + }, + iso8601: function importDurationForEncode(_, module) { + module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); + return { + encodeTemplate: "Duration.toISO8601({})", + decodeTemplate: "Duration.parseISO8601({})", + }; + }, + }, + }, + defaultEncodings: { + byMimeType: { + "application/json": ["TypeSpec.string", "iso8601"], + }, + }, + isJsonCompatible: false, +}; + +const NUMBER: ScalarInfo = { + type: "number", + encodings: { + "TypeSpec.string": { + default: { + encodeTemplate: "globalThis.String({})", + decodeTemplate: "globalThis.Number({})", + }, + }, + }, + isJsonCompatible: true, +}; + +/** + * Declarative scalar table. + * + * This table defines how TypeSpec scalars are represented in JS/TS. + * + * The entries are the fully-qualified names of scalars, and the values are objects that describe how the scalar + * is represented. + * + * Each representation has a `type`, indicating the TypeScript type that represents the scalar at runtime. + * + * The `encodings` object describes how the scalar can be encoded/decoded to/from other types. Encodings + * are named, and each encoding has an `encodeTemplate` and `decodeTemplate` that describe how to encode and decode + * the scalar to/from the target type using the encoding. Encodings can also optionally have a `via` field that + * indicates that the encoding is a modification of the data yielded by another encoding. + * + * The `defaultEncodings` object describes the default encodings to use for the scalar in various contexts. The + * `byMimeType` object maps MIME types to encoding pairs, and the `http` object maps HTTP metadata contexts to + * encoding pairs. + */ const SCALARS = new Map([ [ "TypeSpec.bytes", @@ -47,13 +169,78 @@ const SCALARS = new Map([ encodings: { "TypeSpec.string": { base64: { - encodeTemplate: "({} instanceof Buffer ? {} : Buffer.from({})).toString('base64')", - decodeTemplate: "Buffer.from({}, 'base64')", + encodeTemplate: + "({} instanceof globalThis.Buffer ? {} : globalThis.Buffer.from({})).toString('base64')", + decodeTemplate: "globalThis.Buffer.from({}, 'base64')", + }, + base64url: { + via: "base64", + encodeTemplate: "globalThis.encodeURIComponent({})", + decodeTemplate: "globalThis.decodeURIComponent({})", }, }, }, + defaultEncodings: { + byMimeType: { "application/json": ["TypeSpec.string", "base64"] }, + }, + isJsonCompatible: false, }, ], + [ + "TypeSpec.boolean", + { + type: "boolean", + encodings: { + "TypeSpec.string": { + default: { + encodeTemplate: "globalThis.String({})", + decodeTemplate: '({} === "false" ? false : globalThis.Boolean({}))', + }, + }, + }, + isJsonCompatible: true, + }, + ], + [ + "TypeSpec.string", + { + type: "string", + encodings: { "TypeSpec.string": { default: { encodeTemplate: "{}", decodeTemplate: "{}" } } }, + isJsonCompatible: true, + }, + ], + + ["TypeSpec.float32", NUMBER], + ["TypeSpec.float64", NUMBER], + ["TypeSpec.uint32", NUMBER], + ["TypeSpec.uint16", NUMBER], + ["TypeSpec.uint8", NUMBER], + ["TypeSpec.int32", NUMBER], + ["TypeSpec.int16", NUMBER], + ["TypeSpec.int8", NUMBER], + ["TypeSpec.safeint", NUMBER], + + [ + "TypeSpec.integer", + { + type: "bigint", + encodings: { + "TypeSpec.string": { + default: { + encodeTemplate: "globalThis.String({})", + decodeTemplate: "globalThis.BigInt({})", + }, + }, + }, + isJsonCompatible: false, + }, + ], + ["TypeSpec.plainDate", { type: "Date", isJsonCompatible: false }], + ["TypeSpec.plainTime", { type: "Date", isJsonCompatible: false }], + ["TypeSpec.utcDateTime", { type: "Date", isJsonCompatible: false }], + ["TypeSpec.offsetDateTime", { type: "Date", isJsonCompatible: false }], + ["TypeSpec.unixTimestamp32", { type: "Date", isJsonCompatible: false }], + ["TypeSpec.duration", TYPESPEC_DURATION], ]); /** @@ -62,122 +249,333 @@ const SCALARS = new Map([ * This is rare in TypeScript, as the scalar will ordinarily be used inline, but may be desirable in some cases. * * @param ctx - The emitter context. + * @param module - The module that the scalar is being emitted in. * @param scalar - The scalar to emit. * @returns a string that declares an alias to the scalar type in TypeScript. */ -export function emitScalar(ctx: JsContext, scalar: Scalar): string { - const jsScalar = getJsScalar(ctx.program, scalar, scalar.node.id); +export function emitScalar(ctx: JsContext, scalar: Scalar, module: Module): string { + const jsScalar = getJsScalar(ctx, module, scalar, scalar.node.id); const name = parseCase(scalar.name).pascalCase; - return `type ${name} = ${jsScalar};`; + return `type ${name} = ${jsScalar.type};`; } -/** - * Get the string parsing template for a given scalar. - * - * It is common that a scalar type is encoded as a string. For example, in HTTP path parameters or query parameters - * where the value may be an integer, but the APIs expose it as a string. In such cases the parse template may be - * used to coerce the string value to the correct scalar type. - * - * The result of this function contains the string "{}" exactly once, which should be replaced with the text of an - * expression evaluating to the string representation of the scalar. - * - * For example, scalars that are represented by JS `number` are parsed with the template `Number({})`, which will - * convert the string to a number. - * - * @param ctx - The emitter context. - * @param scalar - The scalar to parse from a string - * @returns a template expression string that can be used to parse a string into the scalar type. - */ -export function parseTemplateForScalar(ctx: JsContext, scalar: Scalar): string { - const jsScalar = getJsScalar(ctx.program, scalar, scalar); - - switch (jsScalar) { - case "string": - return "{}"; - case "number": - return "Number({})"; - case "bigint": - return "BigInt({})"; - case "Uint8Array": - return "Buffer.from({}, 'base64')"; - default: - throw new UnimplementedError(`parse template for scalar '${jsScalar}'`); - } +interface Contextualized { + (ctx: JsContext, module: Module): T; } -/** - * Get the string encoding template for a given scalar. - * @param ctx - * @param scalar - */ -export function encodeTemplateForScalar(ctx: JsContext, scalar: Scalar): string { - const jsScalar = getJsScalar(ctx.program, scalar, scalar); - - switch (jsScalar) { - case "string": - return "{}"; - case "number": - return "String({})"; - case "bigint": - return "String({})"; - case "Uint8Array": - return "{}.toString('base64')"; - default: - throw new UnimplementedError(`encode template for scalar '${jsScalar}'`); - } -} +type ScalarStore = Map>; -const __JS_SCALARS_MAP = new Map>(); +const __JS_SCALARS_MAP = new WeakMap(); -function getScalarsMap(program: Program): Map { +function getScalarStore(program: Program): ScalarStore { let scalars = __JS_SCALARS_MAP.get(program); if (scalars === undefined) { - scalars = createScalarsMap(program); + scalars = createScalarStore(program); __JS_SCALARS_MAP.set(program, scalars); } return scalars; } -function createScalarsMap(program: Program): Map { - const entries = [ - [program.resolveTypeReference("TypeSpec.bytes"), "Uint8Array"], - [program.resolveTypeReference("TypeSpec.boolean"), "boolean"], - [program.resolveTypeReference("TypeSpec.string"), "string"], - [program.resolveTypeReference("TypeSpec.float32"), "number"], - [program.resolveTypeReference("TypeSpec.float64"), "number"], - - [program.resolveTypeReference("TypeSpec.uint32"), "number"], - [program.resolveTypeReference("TypeSpec.uint16"), "number"], - [program.resolveTypeReference("TypeSpec.uint8"), "number"], - [program.resolveTypeReference("TypeSpec.int32"), "number"], - [program.resolveTypeReference("TypeSpec.int16"), "number"], - [program.resolveTypeReference("TypeSpec.int8"), "number"], - - [program.resolveTypeReference("TypeSpec.safeint"), "number"], - [program.resolveTypeReference("TypeSpec.integer"), "bigint"], - [program.resolveTypeReference("TypeSpec.plainDate"), "Date"], - [program.resolveTypeReference("TypeSpec.plainTime"), "Date"], - [program.resolveTypeReference("TypeSpec.utcDateTime"), "Date"], - ] as const; - - for (const [[type, diagnostics]] of entries) { - if (!type) { - const diagnosticString = diagnostics.map((x) => formatDiagnostic(x)).join("\n"); - throw new Error(`failed to construct TypeSpec -> JavaScript scalar map: ${diagnosticString}`); - } else if (type.kind !== "Scalar") { - throw new Error( - `type ${(type as any).name ?? ""} is a '${type.kind}', expected 'scalar'`, - ); +function createScalarStore(program: Program): ScalarStore { + const m = new Map>(); + + for (const [scalarName, scalarInfo] of SCALARS) { + const [scalar, diagnostics] = program.resolveTypeReference(scalarName); + + if (diagnostics.length > 0 || !scalar || scalar.kind !== "Scalar") { + throw new UnreachableError(`Failed to resolve built-in scalar '${scalarName}'`); + } + + m.set(scalar, createJsScalar(program, scalar, scalarInfo, m)); + } + + return m; +} + +function createJsScalar( + program: Program, + scalar: Scalar, + scalarInfo: ScalarInfo, + store: ScalarStore, +): Contextualized { + return (ctx, module) => { + const _http: { [K in HttpOperationParameter["type"]]?: Encoder } = {}; + const self = { + get type() { + return typeof scalarInfo.type === "function" + ? scalarInfo.type(ctx, module) + : scalarInfo.type; + }, + + scalar, + + getEncoding(encoding: string, target: Scalar): Encoder | undefined { + encoding = encoding.toLowerCase(); + let encodingSpec = scalarInfo.encodings?.[getFullyQualifiedTypeName(target)]?.[encoding]; + + if (encodingSpec === undefined) { + return undefined; + } + + encodingSpec = + typeof encodingSpec === "function" ? encodingSpec(ctx, module) : encodingSpec; + + let _decodeTemplate: string | undefined = undefined; + let _encodeTemplate: string | undefined = undefined; + + return { + get target() { + return store.get(target)!(ctx, module); + }, + + decode(subject) { + _decodeTemplate ??= + typeof encodingSpec.decodeTemplate === "function" + ? encodingSpec.decodeTemplate(ctx, module) + : encodingSpec.decodeTemplate; + + subject = `(${subject})`; + + // If we have a via, decode it last + + subject = _decodeTemplate.replaceAll("{}", subject); + + if (encodingSpec.via) { + const via = self.getEncoding(encodingSpec.via, target); + + if (via === undefined) { + return subject; + } + + subject = via.decode(subject); + } + + return subject; + }, + + encode(subject) { + _encodeTemplate ??= + typeof encodingSpec.encodeTemplate === "function" + ? encodingSpec.encodeTemplate(ctx, module) + : encodingSpec.encodeTemplate; + + subject = `(${subject})`; + + // If we have a via, encode to it first + + if (encodingSpec.via) { + const via = self.getEncoding(encodingSpec.via, target); + + if (via === undefined) { + return subject; + } + + subject = via.encode(subject); + } + + subject = _encodeTemplate.replaceAll("{}", subject); + + return subject; + }, + }; + }, + + getDefaultMimeEncoding(target: string): Encoder | undefined { + const encoding = scalarInfo.defaultEncodings?.byMimeType?.[target]; + + if (encoding === undefined) { + return undefined; + } + + const [encodingType, encodingName] = encoding; + + const [encodingScalar, diagnostics] = program.resolveTypeReference(encodingType); + + if (diagnostics.length > 0 || !encodingScalar || encodingScalar.kind !== "Scalar") { + throw new UnreachableError(`Failed to resolve built-in scalar '${encodingType}'`); + } + + return self.getEncoding(encodingName, encodingScalar); + }, + + http: { + get header(): Encoder { + return (_http.header ??= getHttpEncoder(ctx, module, self, "header")); + }, + get query(): Encoder { + return (_http.query ??= getHttpEncoder(ctx, module, self, "query")); + }, + get cookie(): Encoder { + return (_http.cookie ??= getHttpEncoder(ctx, module, self, "cookie")); + }, + get path(): Encoder { + return (_http.path ??= getHttpEncoder(ctx, module, self, "path")); + }, + }, + + isJsonCompatible: scalarInfo.isJsonCompatible, + }; + + return self; + }; + + function getHttpEncoder( + ctx: JsContext, + module: Module, + self: JsScalar, + form: HttpOperationParameter["type"], + ) { + const [target, encoding] = scalarInfo.defaultEncodings?.http?.[form] ?? [ + "TypeSpec.string", + "default", + ]; + + const [targetScalar, diagnostics] = program.resolveTypeReference(target); + + if (diagnostics.length > 0 || !targetScalar || targetScalar.kind !== "Scalar") { + throw new UnreachableError(`Failed to resolve built-in scalar '${target}'`); } + + let encoder = self.getEncoding(encoding, targetScalar); + + if (encoder === undefined && scalarInfo.defaultEncodings?.http?.[form]) { + throw new UnreachableError(`Default HTTP ${form} encoding specified but failed to resolve.`); + } + + encoder ??= getDefaultHttpStringEncoder(ctx, module, form); + + return encoder; } +} - return new Map(entries.map(([[type], scalar]) => [type! as Scalar, scalar])); +const REPORTED_UNRECOGNIZED_SCALARS = new WeakMap>(); + +export function reportUnrecognizedScalar( + ctx: JsContext, + scalar: Scalar, + target: DiagnosticTarget | typeof NoTarget, +) { + let reported = REPORTED_UNRECOGNIZED_SCALARS.get(ctx.program); + + if (reported === undefined) { + reported = new Set(); + REPORTED_UNRECOGNIZED_SCALARS.set(ctx.program, reported); + } + + if (reported.has(scalar)) { + return; + } + + reportDiagnostic(ctx.program, { + code: "unrecognized-scalar", + target: target, + format: { + scalar: getFullyQualifiedTypeName(scalar), + }, + }); + + reported.add(scalar); } +function getDefaultHttpStringEncoder( + ctx: JsContext, + module: Module, + form: HttpOperationParameter["type"], +): Encoder { + const [string, diagnostics] = ctx.program.resolveTypeReference("TypeSpec.string"); + + if (diagnostics.length > 0 || !string || string.kind !== "Scalar") { + throw new UnreachableError(`Failed to resolve built-in scalar 'TypeSpec.string'`); + } + + const scalar = getJsScalar(ctx, module, string, NoTarget); + + const encode = form === "path" ? HTTP_ENCODE_STRING_URLENCODED : HTTP_ENCODE_STRING; + const decode = form === "path" ? HTTP_DECODE_STRING_URLENCODED : HTTP_DECODE_STRING; + + return { + target: scalar, + encode, + decode, + }; +} + +const HTTP_ENCODE_STRING: Encoder["encode"] = (subject) => `JSON.stringify(${subject})`; +const HTTP_DECODE_STRING: Encoder["decode"] = (subject) => `JSON.parse(${subject})`; + +const HTTP_ENCODE_STRING_URLENCODED: Encoder["encode"] = (subject) => + `encodeURIComponent(JSON.stringify(${subject}))`; +const HTTP_DECODE_STRING_URLENCODED: Encoder["decode"] = (subject) => + `JSON.parse(decodeURIComponent(${subject}))`; + +export interface Encoder { + readonly target: JsScalar; + encode(subject: string): string; + decode(subject: string): string; +} + +export interface JsScalar { + readonly type: string; + + readonly scalar?: Scalar; + + getEncoding(encoding: string, target: Scalar): Encoder | undefined; + + getDefaultMimeEncoding(mimeType: string): Encoder | undefined; + + isJsonCompatible: boolean; + + readonly http: { + readonly [K in HttpOperationParameter["type"]]: Encoder; + }; +} + +const DEFAULT_STRING_ENCODER_RAW: Omit = { + encode(subject) { + return `String(${subject})`; + }, + decode(subject) { + return `${subject}`; + }, +}; + +export const JSSCALAR_UNKNOWN: JsScalar = { + type: "unknown", + getEncoding: () => undefined, + getDefaultMimeEncoding: () => undefined, + http: { + get header() { + return { + target: JSSCALAR_UNKNOWN, + ...DEFAULT_STRING_ENCODER_RAW, + }; + }, + get query() { + return { + target: JSSCALAR_UNKNOWN, + ...DEFAULT_STRING_ENCODER_RAW, + }; + }, + get cookie() { + return { + target: JSSCALAR_UNKNOWN, + ...DEFAULT_STRING_ENCODER_RAW, + }; + }, + get path() { + return { + target: JSSCALAR_UNKNOWN, + ...DEFAULT_STRING_ENCODER_RAW, + }; + }, + }, + isJsonCompatible: true, +}; + /** * Gets a TypeScript type that can represent a given TypeSpec scalar. * @@ -191,11 +589,12 @@ function createScalarsMap(program: Program): Map { * @returns a string containing a TypeScript type that can represent the scalar */ export function getJsScalar( - program: Program, + ctx: JsContext, + module: Module, scalar: Scalar, diagnosticTarget: DiagnosticTarget | typeof NoTarget, -): string { - const scalars = getScalarsMap(program); +): JsScalar { + const scalars = getScalarStore(ctx.program); let _scalar: Scalar | undefined = scalar; @@ -203,19 +602,13 @@ export function getJsScalar( const jsScalar = scalars.get(_scalar); if (jsScalar !== undefined) { - return jsScalar; + return jsScalar(ctx, module); } _scalar = _scalar.baseScalar; } - reportDiagnostic(program, { - code: "unrecognized-scalar", - target: diagnosticTarget, - format: { - scalar: getFullyQualifiedTypeName(scalar), - }, - }); + reportUnrecognizedScalar(ctx, scalar, diagnosticTarget); - return "unknown"; + return JSSCALAR_UNKNOWN; } diff --git a/packages/http-server-javascript/src/common/serialization/index.ts b/packages/http-server-javascript/src/common/serialization/index.ts index 73ee763670..b0320b028b 100644 --- a/packages/http-server-javascript/src/common/serialization/index.ts +++ b/packages/http-server-javascript/src/common/serialization/index.ts @@ -55,9 +55,15 @@ export function emitSerialization(ctx: JsContext): void { const serializations = _SERIALIZATIONS_MAP.get(type)!; const requiredSerializations = new Set( - [...serializations].filter((serialization) => - isSerializationRequired(ctx, type, serialization), - ), + [...serializations].filter((serialization) => { + const isSynthetic = ctx.syntheticNames.has(type) || !type.namespace; + + const module = isSynthetic + ? ctx.syntheticModule + : createOrGetModuleForNamespace(ctx, type.namespace!); + + return isSerializationRequired(ctx, module, type, serialization); + }), ); if (requiredSerializations.size > 0) { @@ -68,12 +74,13 @@ export function emitSerialization(ctx: JsContext): void { export function isSerializationRequired( ctx: JsContext, + module: Module, type: Type, serialization: SerializationContentType, ): boolean { switch (serialization) { case "application/json": { - return requiresJsonSerialization(ctx, type); + return requiresJsonSerialization(ctx, module, type); } default: throw new Error(`Unreachable: serialization content type ${serialization satisfies never}`); diff --git a/packages/http-server-javascript/src/common/serialization/json.ts b/packages/http-server-javascript/src/common/serialization/json.ts index ec1fa43ad4..e3803d928c 100644 --- a/packages/http-server-javascript/src/common/serialization/json.ts +++ b/packages/http-server-javascript/src/common/serialization/json.ts @@ -3,10 +3,12 @@ import { BooleanLiteral, + DiagnosticTarget, IntrinsicType, ModelProperty, NoTarget, NumericLiteral, + Scalar, StringLiteral, Type, compilerAssert, @@ -18,12 +20,15 @@ import { } from "@typespec/compiler"; import { getHeaderFieldOptions, getPathParamOptions, getQueryParamOptions } from "@typespec/http"; import { JsContext, Module } from "../../ctx.js"; -import { parseCase } from "../../util/case.js"; +import { reportDiagnostic } from "../../lib.js"; +import { access, parseCase } from "../../util/case.js"; import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js"; import { UnimplementedError } from "../../util/error.js"; import { indent } from "../../util/iter.js"; +import { keywordSafe } from "../../util/keywords.js"; +import { getFullyQualifiedTypeName } from "../../util/name.js"; import { emitTypeReference, escapeUnsafeChars } from "../reference.js"; -import { getJsScalar } from "../scalar.js"; +import { Encoder, JSSCALAR_UNKNOWN, JsScalar, getJsScalar } from "../scalar.js"; import { SerializableType, SerializationContext, requireSerialization } from "./index.js"; /** @@ -31,7 +36,12 @@ import { SerializableType, SerializationContext, requireSerialization } from "./ */ const _REQUIRES_JSON_SERIALIZATION = new WeakMap(); -export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean { +export function requiresJsonSerialization( + ctx: JsContext, + module: Module, + type: Type, + diagnosticTarget: DiagnosticTarget | typeof NoTarget = NoTarget, +): boolean { if (!isSerializable(type)) return false; if (_REQUIRES_JSON_SERIALIZATION.has(type)) { @@ -49,28 +59,31 @@ export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean { case "Model": { if (isArrayModelType(ctx.program, type)) { const argumentType = type.indexer.value; - requiresSerialization = requiresJsonSerialization(ctx, argumentType); + requiresSerialization = requiresJsonSerialization(ctx, module, argumentType); break; } requiresSerialization = [...type.properties.values()].some((property) => - propertyRequiresJsonSerialization(ctx, property), + propertyRequiresJsonSerialization(ctx, module, property), ); break; } case "Scalar": { - const scalar = getJsScalar(ctx.program, type, type); - requiresSerialization = scalar === "Uint8Array" || getEncode(ctx.program, type) !== undefined; + const scalar = getJsScalar(ctx, module, type, diagnosticTarget); + requiresSerialization = + !scalar.isJsonCompatible || + getEncode(ctx.program, type) !== undefined || + scalar.getDefaultMimeEncoding("application/json") !== undefined; break; } case "Union": { requiresSerialization = [...type.variants.values()].some((variant) => - requiresJsonSerialization(ctx, variant), + requiresJsonSerialization(ctx, module, variant), ); break; } case "ModelProperty": - requiresSerialization = requiresJsonSerialization(ctx, type.type); + requiresSerialization = requiresJsonSerialization(ctx, module, type.type); break; } @@ -79,13 +92,18 @@ export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean { return requiresSerialization; } -function propertyRequiresJsonSerialization(ctx: JsContext, property: ModelProperty): boolean { +function propertyRequiresJsonSerialization( + ctx: JsContext, + module: Module, + property: ModelProperty, +): boolean { return !!( isHttpMetadata(ctx, property) || getEncode(ctx.program, property) || resolveEncodedName(ctx.program, property, "application/json") !== property.name || getProjectedName(ctx.program, property, "json") || - (isSerializable(property.type) && requiresJsonSerialization(ctx, property.type)) + (isSerializable(property.type) && + requiresJsonSerialization(ctx, module, property.type, property)) ); } @@ -136,12 +154,41 @@ function* emitToJson( resolveEncodedName(ctx.program, property, "application/json") ?? property.name; - const expr = transposeExpressionToJson( - ctx, - property.type, - `input.${property.name}`, - module, - ); + const propertyName = keywordSafe(parseCase(property.name).camelCase); + + let expr: string = access("input", propertyName); + + const encoding = getEncode(ctx.program, property); + + if (property.type.kind === "Scalar" && encoding) { + const scalar = getJsScalar(ctx, module, property.type, property.type); + const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type); + + if (scalarEncoder) { + // Scalar must be defined here because we resolved an encoding. It can only be undefined when it comes + // from an `unknown` rendering, which cannot appear in a resolved encoding. + expr = transposeExpressionToJson( + ctx, + scalarEncoder.target.scalar!, + scalarEncoder.encode(expr), + module, + ); + } else { + reportDiagnostic(ctx.program, { + code: "unknown-encoding", + target: NoTarget, + format: { + encoding: encoding.encoding ?? "", + type: getFullyQualifiedTypeName(property.type), + target: getFullyQualifiedTypeName(encoding.type), + }, + }); + + // We treat this as unknown from here on out. The encoding was not deciphered. + } + } else { + expr = transposeExpressionToJson(ctx, property.type, expr, module); + } yield ` ${encodedName}: ${expr},`; } @@ -155,12 +202,12 @@ function* emitToJson( return; } case "Union": { - const codeTree = differentiateUnion(ctx, type); + const codeTree = differentiateUnion(ctx, module, type); yield* writeCodeTree(ctx, codeTree, { subject: "input", referenceModelProperty(p) { - return "input." + parseCase(p.name).camelCase; + return access("input", parseCase(p.name).camelCase); }, renderResult(type) { return [`return ${transposeExpressionToJson(ctx, type, "input", module)};`]; @@ -183,15 +230,15 @@ function transposeExpressionToJson( if (isArrayModelType(ctx.program, type)) { const argumentType = type.indexer.value; - if (requiresJsonSerialization(ctx, argumentType)) { - return `${expr}?.map((item) => ${transposeExpressionToJson(ctx, argumentType, "item", module)})`; + if (requiresJsonSerialization(ctx, module, argumentType)) { + return `(${expr})?.map((item) => ${transposeExpressionToJson(ctx, argumentType, "item", module)})`; } else { return expr; } } else if (isRecordModelType(ctx.program, type)) { const argumentType = type.indexer.value; - if (requiresJsonSerialization(ctx, argumentType)) { + if (requiresJsonSerialization(ctx, module, argumentType)) { return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [String(key), ${transposeExpressionToJson( ctx, argumentType, @@ -201,7 +248,7 @@ function transposeExpressionToJson( } else { return expr; } - } else if (!requiresJsonSerialization(ctx, type)) { + } else if (!requiresJsonSerialization(ctx, module, type)) { return expr; } else { requireSerialization(ctx, type, "application/json"); @@ -211,18 +258,19 @@ function transposeExpressionToJson( } } case "Scalar": - const scalar = getJsScalar(ctx.program, type, type); + const scalar = getJsScalar(ctx, module, type, NoTarget); - switch (scalar) { - case "Uint8Array": - // Coerce to Buffer if we aren't given a buffer. This avoids having to do unholy things to - // convert through an intermediate and use globalThis.btoa. v8 does not support Uint8Array.toBase64 - return `((${expr} instanceof Buffer) ? ${expr} : Buffer.from(${expr})).toString('base64')`; - default: - return expr; + const encoder: Encoder = getScalarEncoder(ctx, type, scalar); + + const encoded = encoder.encode(expr); + + if (encoder.target.isJsonCompatible || !encoder.target.scalar) { + return encoded; + } else { + return transposeExpressionToJson(ctx, encoder.target.scalar, encoded, module); } case "Union": - if (!requiresJsonSerialization(ctx, type)) { + if (!requiresJsonSerialization(ctx, module, type)) { return expr; } else { requireSerialization(ctx, type, "application/json"); @@ -276,6 +324,47 @@ function transposeExpressionToJson( } } +function getScalarEncoder(ctx: SerializationContext, type: Scalar, scalar: JsScalar) { + const encoding = getEncode(ctx.program, type); + + let encoder: Encoder; + + if (encoding) { + const encodingName = encoding.encoding ?? "default"; + const scalarEncoder = scalar.getEncoding(encodingName, encoding.type); + + // TODO/witemple - we should detect this before realizing models and use a transform to + // represent the defective scalar as the encoding target type. + if (!scalarEncoder) { + reportDiagnostic(ctx.program, { + code: "unknown-encoding", + target: NoTarget, + format: { + encoding: encoding.encoding ?? "", + type: getFullyQualifiedTypeName(type), + target: getFullyQualifiedTypeName(encoding.type), + }, + }); + + encoder = { + target: JSSCALAR_UNKNOWN, + encode: (expr) => expr, + decode: (expr) => expr, + }; + } else { + encoder = scalarEncoder; + } + } else { + // No encoding specified, use the default content type encoding for json + encoder = scalar.getDefaultMimeEncoding("application/json") ?? { + target: JSSCALAR_UNKNOWN, + encode: (expr) => expr, + decode: (expr) => expr, + }; + } + return encoder; +} + function literalToExpr(type: StringLiteral | BooleanLiteral | NumericLiteral): string { switch (type.kind) { case "String": @@ -301,14 +390,41 @@ function* emitFromJson( resolveEncodedName(ctx.program, property, "application/json") ?? property.name; - const expr = transposeExpressionFromJson( - ctx, - property.type, - `input["${encodedName}"]`, - module, - ); + let expr = access("input", encodedName); + + const encoding = getEncode(ctx.program, property); + + if (property.type.kind === "Scalar" && encoding) { + const scalar = getJsScalar(ctx, module, property.type, property.type); + const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type); + + if (scalarEncoder) { + expr = transposeExpressionFromJson( + ctx, + scalarEncoder.target.scalar!, + scalarEncoder.decode(expr), + module, + ); + } else { + reportDiagnostic(ctx.program, { + code: "unknown-encoding", + target: NoTarget, + format: { + encoding: encoding.encoding ?? "", + type: getFullyQualifiedTypeName(property.type), + target: getFullyQualifiedTypeName(encoding.type), + }, + }); + + // We treat this as unknown from here on out. The encoding was not decipher + } + } else { + expr = transposeExpressionFromJson(ctx, property.type, expr, module); + } + + const propertyName = keywordSafe(parseCase(property.name).camelCase); - yield ` ${property.name}: ${expr},`; + yield ` ${propertyName}: ${expr},`; } yield "};"; @@ -320,7 +436,7 @@ function* emitFromJson( return; } case "Union": { - const codeTree = differentiateUnion(ctx, type); + const codeTree = differentiateUnion(ctx, module, type); yield* writeCodeTree(ctx, codeTree, { subject: "input", @@ -329,7 +445,7 @@ function* emitFromJson( getProjectedName(ctx.program, p, "json") ?? resolveEncodedName(ctx.program, p, "application/json") ?? p.name; - return "input[" + JSON.stringify(jsonName) + "]"; + return access("input", jsonName); }, renderResult(type) { return [`return ${transposeExpressionFromJson(ctx, type, "input", module)};`]; @@ -352,15 +468,15 @@ function transposeExpressionFromJson( if (isArrayModelType(ctx.program, type)) { const argumentType = type.indexer.value; - if (requiresJsonSerialization(ctx, argumentType)) { - return `${expr}?.map((item: any) => ${transposeExpressionFromJson(ctx, argumentType, "item", module)})`; + if (requiresJsonSerialization(ctx, module, argumentType)) { + return `(${expr})?.map((item: any) => ${transposeExpressionFromJson(ctx, argumentType, "item", module)})`; } else { return expr; } } else if (isRecordModelType(ctx.program, type)) { const argumentType = type.indexer.value; - if (requiresJsonSerialization(ctx, argumentType)) { + if (requiresJsonSerialization(ctx, module, argumentType)) { return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [key, ${transposeExpressionFromJson( ctx, argumentType, @@ -370,7 +486,7 @@ function transposeExpressionFromJson( } else { return expr; } - } else if (!requiresJsonSerialization(ctx, type)) { + } else if (!requiresJsonSerialization(ctx, module, type)) { return `${expr} as ${emitTypeReference(ctx, type, NoTarget, module)}`; } else { requireSerialization(ctx, type, "application/json"); @@ -380,16 +496,19 @@ function transposeExpressionFromJson( } } case "Scalar": - const scalar = getJsScalar(ctx.program, type, type); + const scalar = getJsScalar(ctx, module, type, type); - switch (scalar) { - case "Uint8Array": - return `Buffer.from(${expr}, 'base64')`; - default: - return expr; + const encoder = getScalarEncoder(ctx, type, scalar); + + const decoded = encoder.decode(expr); + + if (encoder.target.isJsonCompatible || !encoder.target.scalar) { + return decoded; + } else { + return transposeExpressionFromJson(ctx, encoder.target.scalar, decoded, module); } case "Union": - if (!requiresJsonSerialization(ctx, type)) { + if (!requiresJsonSerialization(ctx, module, type)) { return expr; } else { requireSerialization(ctx, type, "application/json"); diff --git a/packages/http-server-javascript/src/helpers/datetime.ts b/packages/http-server-javascript/src/helpers/datetime.ts new file mode 100644 index 0000000000..6090637a6d --- /dev/null +++ b/packages/http-server-javascript/src/helpers/datetime.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +// #region Duration + +/** + * Regular expression for matching ISO8601 duration strings. + * + * Yields: + * - 0: the full match + * - 1: the sign (optional) + * - 2: years (optional) + * - 3: months (optional) + * - 4: weeks (optional) + * - 5: days (optional) + * - 6: hours (optional) + * - 7: minutes (optional) + * - 8: seconds (optional) + */ +const ISO8601_DURATION_REGEX = + /^(-)?P(?:((?:\d*[.,])?\d+)Y)?(?:((?:\d*[.,])?\d+)M)?(?:((?:\d*[.,])?\d+)W)?(?:((?:\d*[.,])?\d+)D)?(?:T(?:((?:\d*[.,])?\d+)H)?(?:((?:\d*[.,])?\d+)M)?(?:((?:\d*[.,])?\d+)S)?)?$/; + +/** + * A duration of time, measured in years, months, weeks, days, hours, minutes, and seconds. + * + * The values may be fractional and are not normalized (e.g. 36 hours is not the same duration as 1 day and 12 hours + * when accounting for Daylight Saving Time changes or leap seconds). + * + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations + */ +export interface Duration { + /** + * "+" if the duration is positive, "-" if the duration is negative. + */ + sign: "+" | "-"; + /** + * The number of years in the duration. + */ + years: number; + /** + * The number of months in the duration. + */ + months: number; + /** + * The number of weeks in the duration. + */ + weeks: number; + /** + * The number of days in the duration. + */ + days: number; + /** + * The number of hours in the duration. + */ + hours: number; + /** + * The number of minutes in the duration. + */ + minutes: number; + /** + * The number of seconds in the duration. + */ + seconds: number; +} + +export const Duration = Object.freeze({ + /** + * Parses an ISO8601 duration string into an object. + * + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations + * + * @param duration - the duration string to parse + * @returns an object containing the parsed duration + */ + parseISO8601(duration: string, maxLength: number = 100): Duration { + duration = duration.trim(); + if (duration.length > maxLength) + throw new Error(`ISO8601 duration string is too long: ${duration}`); + + const match = duration.match(ISO8601_DURATION_REGEX); + + if (!match) throw new Error(`Invalid ISO8601 duration: ${duration}`); + + return { + sign: match[1] === undefined ? "+" : (match[1] as Duration["sign"]), + years: parseFloatNormal(match[2]), + months: parseFloatNormal(match[3]), + weeks: parseFloatNormal(match[4]), + days: parseFloatNormal(match[5]), + hours: parseFloatNormal(match[6]), + minutes: parseFloatNormal(match[7]), + seconds: parseFloatNormal(match[8]), + }; + + function parseFloatNormal(match: string | undefined): number { + if (match === undefined) return 0; + + const normalized = match.replace(",", "."); + + const parsed = parseFloat(normalized); + + if (isNaN(parsed)) + throw new Error(`Unreachable: Invalid number in ISO8601 duration string: ${match}`); + + return parsed; + } + }, + /** + * Writes a Duration to an ISO8601 duration string. + * + * @see https://en.wikipedia.org/wiki/ISO_8601#Durations + * + * @param duration - the duration to write to a string + * @returns a string in ISO8601 duration format + */ + toISO8601(duration: Duration): string { + const sign = duration.sign === "+" ? "" : "-"; + + const years = + duration.years !== 0 && !isNaN(Number(duration.years)) ? `${duration.years}Y` : ""; + const months = + duration.months !== 0 && !isNaN(Number(duration.months)) ? `${duration.months}M` : ""; + const weeks = + duration.weeks !== 0 && !isNaN(Number(duration.weeks)) ? `${duration.weeks}W` : ""; + const days = duration.days !== 0 && !isNaN(Number(duration.days)) ? `${duration.days}D` : ""; + + let time = ""; + + const _hours = duration.hours !== 0 && !isNaN(Number(duration.hours)); + const _minutes = duration.minutes !== 0 && !isNaN(Number(duration.minutes)); + const _seconds = duration.seconds !== 0 && !isNaN(Number(duration.seconds)); + + if (_hours || _minutes || _seconds) { + const hours = _hours ? `${duration.hours}H` : ""; + const minutes = _minutes ? `${duration.minutes}M` : ""; + const seconds = _seconds ? `${duration.seconds}S` : ""; + + time = `T${hours}${minutes}${seconds}`; + } + + return `${sign}P${years}${months}${weeks}${days}${time}`; + }, +}); + +// #endregion diff --git a/packages/http-server-javascript/src/http/server/index.ts b/packages/http-server-javascript/src/http/server/index.ts index 947fc4d498..c210d3f311 100644 --- a/packages/http-server-javascript/src/http/server/index.ts +++ b/packages/http-server-javascript/src/http/server/index.ts @@ -12,7 +12,6 @@ import { } from "@typespec/http"; import { createOrGetModuleForNamespace } from "../../common/namespace.js"; import { emitTypeReference, isValueLiteralType } from "../../common/reference.js"; -import { parseTemplateForScalar } from "../../common/scalar.js"; import { SerializableType, isSerializationRequired, @@ -32,6 +31,7 @@ import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js"; import { emitMultipart, emitMultipartLegacy } from "./multipart.js"; import { module as headerHelpers } from "../../../generated-defs/helpers/header.js"; +import { getJsScalar } from "../../common/scalar.js"; import { requiresJsonSerialization } from "../../common/serialization/json.js"; const DEFAULT_CONTENT_TYPE = "application/json"; @@ -111,7 +111,7 @@ function* emitRawServerOperation( const queryParams: Extract[] = []; - const parsedParams = new Set(); + const parsedParams = new Map(); for (const parameter of operation.parameters.parameters) { const resolvedParameter = @@ -124,11 +124,11 @@ function* emitRawServerOperation( throw new UnimplementedError("cookie parameters"); case "query": queryParams.push(parameter); - parsedParams.add(resolvedParameter); + parsedParams.set(resolvedParameter, parameter); break; case "path": // Already handled above. - parsedParams.add(resolvedParameter); + parsedParams.set(resolvedParameter, parameter); break; default: throw new Error( @@ -212,7 +212,7 @@ function* emitRawServerOperation( let value: string; - if (requiresJsonSerialization(ctx, body.type)) { + if (requiresJsonSerialization(ctx, module, body.type)) { value = `${bodyTypeName}.fromJsonObject(JSON.parse(body))`; } else { value = `JSON.parse(body)`; @@ -267,13 +267,17 @@ function* emitRawServerOperation( } else { const resolvedParameter = param.type.kind === "ModelProperty" ? param.type : param; - paramBaseExpression = - resolvedParameter.type.kind === "Scalar" && parsedParams.has(resolvedParameter) - ? parseTemplateForScalar(ctx, resolvedParameter.type).replace( - "{}", - paramNameCase.camelCase, - ) - : paramNameCase.camelCase; + const httpOperationParam = parsedParams.get(resolvedParameter); + + if (resolvedParameter.type.kind === "Scalar" && httpOperationParam) { + const jsScalar = getJsScalar(ctx, module, resolvedParameter.type, resolvedParameter); + + const encoder = jsScalar.http[httpOperationParam.type]; + + paramBaseExpression = encoder.decode(paramNameCase.camelCase); + } else { + paramBaseExpression = paramNameCase.camelCase; + } } if (param.optional) { @@ -330,7 +334,7 @@ function* emitResultProcessing( // Single target type yield* emitResultProcessingForType(ctx, names, t, module); } else { - const codeTree = differentiateUnion(ctx, t); + const codeTree = differentiateUnion(ctx, module, t); yield* writeCodeTree(ctx, codeTree, { subject: names.result, @@ -398,7 +402,12 @@ function* emitResultProcessingForType( if (body) { const bodyCase = parseCase(body.name); - const serializationRequired = isSerializationRequired(ctx, body.type, "application/json"); + const serializationRequired = isSerializationRequired( + ctx, + module, + body.type, + "application/json", + ); requireSerialization(ctx, body.type, "application/json"); yield `${names.ctx}.response.setHeader("content-type", "application/json");`; @@ -415,7 +424,12 @@ function* emitResultProcessingForType( if (allMetadataIsRemoved) { yield `${names.ctx}.response.end();`; } else { - const serializationRequired = isSerializationRequired(ctx, target, "application/json"); + const serializationRequired = isSerializationRequired( + ctx, + module, + target, + "application/json", + ); requireSerialization(ctx, target, "application/json"); yield `${names.ctx}.response.setHeader("content-type", "application/json");`; diff --git a/packages/http-server-javascript/src/http/server/multipart.ts b/packages/http-server-javascript/src/http/server/multipart.ts index 6ec51fb527..0f77cb53b3 100644 --- a/packages/http-server-javascript/src/http/server/multipart.ts +++ b/packages/http-server-javascript/src/http/server/multipart.ts @@ -167,7 +167,7 @@ export function* emitMultipart( yield ` const __object = JSON.parse(Buffer.concat(__chunks).toString("utf-8"));`; yield ""; - if (requiresJsonSerialization(ctx, namedPart.body.type)) { + if (requiresJsonSerialization(ctx, module, namedPart.body.type)) { const bodyTypeReference = emitTypeReference( ctx, namedPart.body.type, diff --git a/packages/http-server-javascript/src/lib.ts b/packages/http-server-javascript/src/lib.ts index df51603472..340a287033 100644 --- a/packages/http-server-javascript/src/lib.ts +++ b/packages/http-server-javascript/src/lib.ts @@ -113,6 +113,12 @@ export const $lib = createTypeSpecLibrary({ default: "Operation has multiple possible content-type values and cannot be emitted.", }, }, + "unknown-encoding": { + severity: "error", + messages: { + default: paramMessage`Unknown encoding '${"encoding"}' to type '${"target"}' for type '${"type"}'.`, + }, + }, }, }); diff --git a/packages/http-server-javascript/src/util/case.ts b/packages/http-server-javascript/src/util/case.ts index 318712cd5c..02f8878ddd 100644 --- a/packages/http-server-javascript/src/util/case.ts +++ b/packages/http-server-javascript/src/util/case.ts @@ -28,6 +28,25 @@ export function isUnspeakable(name: string): boolean { return true; } +const JS_IDENTIFIER = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +/** + * Returns an access expression for a given subject and key. + * + * If the access can be performed using dot notation, it will. Otherwise, bracket notation will be used. + * + * @param subject - the expression to access + * @param key - the key to access within the subject, must be an index value literal, not an expression + */ +export function access(subject: string, key: string | number): string { + subject = JS_IDENTIFIER.test(subject) ? subject : `(${subject})`; + if (typeof key === "string" && JS_IDENTIFIER.test(key)) { + return `${subject}.${key}`; + } else { + return `${subject}[${JSON.stringify(key)}]`; + } +} + /** * Destructures a name into its components. * diff --git a/packages/http-server-javascript/src/util/differentiate.ts b/packages/http-server-javascript/src/util/differentiate.ts index b42b3cce43..8e452f7ab0 100644 --- a/packages/http-server-javascript/src/util/differentiate.ts +++ b/packages/http-server-javascript/src/util/differentiate.ts @@ -16,7 +16,7 @@ import { getMinValue, } from "@typespec/compiler"; import { getJsScalar } from "../common/scalar.js"; -import { JsContext } from "../ctx.js"; +import { JsContext, Module } from "../ctx.js"; import { reportDiagnostic } from "../lib.js"; import { isUnspeakable, parseCase } from "./case.js"; import { UnimplementedError, UnreachableError } from "./error.js"; @@ -281,6 +281,7 @@ const PROPERTY_ID = (prop: ModelProperty) => parseCase(prop.name).camelCase; */ export function differentiateUnion( ctx: JsContext, + module: Module, union: Union, renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID, ): CodeTree { @@ -301,7 +302,7 @@ export function differentiateUnion( } } - return differentiateTypes(ctx, cases, renderPropertyName); + return differentiateTypes(ctx, module, cases, renderPropertyName); } else { const property = (variants[0].type as Model).properties.get(discriminator)!; @@ -341,6 +342,7 @@ export function differentiateUnion( */ export function differentiateTypes( ctx: JsContext, + module: Module, cases: Set, renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID, ): CodeTree { @@ -364,7 +366,7 @@ export function differentiateTypes( const scalars = (categories.Scalar as Scalar[]) ?? []; if (literals.length + scalars.length === 0) { - return differentiateModelTypes(ctx, select(models, cases), renderPropertyName); + return differentiateModelTypes(ctx, module, select(models, cases), renderPropertyName); } else { const branches: IfBranch[] = []; for (const literal of literals) { @@ -385,7 +387,7 @@ export function differentiateTypes( const scalarRepresentations = new Map(); for (const scalar of scalars) { - const jsScalar = getJsScalar(ctx.program, scalar, scalar); + const jsScalar = getJsScalar(ctx, module, scalar, scalar).type; if (scalarRepresentations.has(jsScalar)) { reportDiagnostic(ctx.program, { @@ -469,7 +471,7 @@ export function differentiateTypes( branches, else: models.length > 0 - ? differentiateModelTypes(ctx, select(models, cases), renderPropertyName) + ? differentiateModelTypes(ctx, module, select(models, cases), renderPropertyName) : undefined, }; } @@ -529,10 +531,14 @@ function getJsValue(ctx: JsContext, literal: JsLiteralType | EnumMember): Litera */ type IntegerRange = [number, number]; -function getIntegerRange(ctx: JsContext, property: ModelProperty): IntegerRange | false { +function getIntegerRange( + ctx: JsContext, + module: Module, + property: ModelProperty, +): IntegerRange | false { if ( property.type.kind === "Scalar" && - getJsScalar(ctx.program, property.type, property) === "number" + getJsScalar(ctx, module, property.type, property).type === "number" ) { const minValue = getMinValue(ctx.program, property); const maxValue = getMaxValue(ctx.program, property); @@ -560,6 +566,7 @@ function overlaps(range: IntegerRange, other: IntegerRange): boolean { */ export function differentiateModelTypes( ctx: JsContext, + module: Module, models: Set, renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID, ): CodeTree { @@ -622,7 +629,7 @@ export function differentiateModelTypes( // CASE - unique range - const range = getIntegerRange(ctx, prop); + const range = getIntegerRange(ctx, module, prop); if (range) { let ranges = propertyRanges.get(renderedPropName); if (!ranges) { diff --git a/packages/http-server-javascript/test/datetime.test.ts b/packages/http-server-javascript/test/datetime.test.ts new file mode 100644 index 0000000000..e3c674996d --- /dev/null +++ b/packages/http-server-javascript/test/datetime.test.ts @@ -0,0 +1,184 @@ +import { deepStrictEqual, strictEqual, throws } from "assert"; +import { describe, it } from "vitest"; +import { Duration } from "../src/helpers/datetime.js"; + +describe("datetime", () => { + describe("duration", () => { + it("parses an ISO8601 duration string", () => { + deepStrictEqual(Duration.parseISO8601("P1Y2M3D"), { + sign: "+", + years: 1, + months: 2, + weeks: 0, + days: 3, + hours: 0, + minutes: 0, + seconds: 0, + }); + }); + + it("parses a negative ISO8601 duration string", () => { + deepStrictEqual(Duration.parseISO8601("-P1Y2M3D"), { + sign: "-", + years: 1, + months: 2, + weeks: 0, + days: 3, + hours: 0, + minutes: 0, + seconds: 0, + }); + }); + + it("parses a duration string with hours and minutes", () => { + deepStrictEqual(Duration.parseISO8601("PT1H2M"), { + sign: "+", + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 1, + minutes: 2, + seconds: 0, + }); + }); + + it("parses a duration string with fractional years", () => { + deepStrictEqual(Duration.parseISO8601("P1.5Y"), { + sign: "+", + years: 1.5, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }); + }); + + it("does not parse an invalid duration string", () => { + throws(() => Duration.parseISO8601("1Y2M3D4H"), { + message: "Invalid ISO8601 duration: 1Y2M3D4H", + }); + }); + + it("does not parse a duration string with too many digits in a component", () => { + throws( + () => + Duration.parseISO8601( + "P123429384502934875023948572039485720394857230948572309485723094857203948572309456789.19821374652345232304958273049582730495827340958720349857452345234529223450928347592834387456928374659238476Y", + ), + { + message: + "ISO8601 duration string is too long: P123429384502934875023948572039485720394857230948572309485723094857203948572309456789.19821374652345232304958273049582730495827340958720349857452345234529223450928347592834387456928374659238476Y", + }, + ); + }); + + it("does not parse a duration string with an invalid group", () => { + throws(() => Duration.parseISO8601("P1Y2M3D4H5X"), { + message: "Invalid ISO8601 duration: P1Y2M3D4H5X", + }); + }); + + it("does not parse a duration string with missing group", () => { + throws(() => Duration.parseISO8601("P1Y2M3D4H5.5"), { + message: "Invalid ISO8601 duration: P1Y2M3D4H5.5", + }); + }); + + it("does not parse a duration string with multiple points", () => { + throws(() => Duration.parseISO8601("P1.2.3Y"), { + message: "Invalid ISO8601 duration: P1.2.3Y", + }); + }); + + it("allows comma as decimal separator", () => { + deepStrictEqual(Duration.parseISO8601("P1,5Y4.2DT1,005S"), { + sign: "+", + years: 1.5, + months: 0, + weeks: 0, + days: 4.2, + hours: 0, + minutes: 0, + seconds: 1.005, + }); + }); + + it("writes an ISO8601 duration string", () => { + const duration: Duration = { + sign: "+", + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7, + }; + + strictEqual(Duration.toISO8601(duration), "P1Y2M3W4DT5H6M7S"); + }); + + it("writes a negative ISO8601 duration string", () => { + const duration: Duration = { + sign: "-", + years: 1, + months: 2, + weeks: 3, + days: 4, + hours: 5, + minutes: 6, + seconds: 7, + }; + + strictEqual(Duration.toISO8601(duration), "-P1Y2M3W4DT5H6M7S"); + }); + + it("writes a duration string with only years", () => { + const duration: Duration = { + sign: "+", + years: 1, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }; + + strictEqual(Duration.toISO8601(duration), "P1Y"); + }); + + it("writes a duration string with only hours", () => { + const duration: Duration = { + sign: "+", + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 36, + minutes: 0, + seconds: 0, + }; + + strictEqual(Duration.toISO8601(duration), "PT36H"); + }); + + it("writes a duration string with fractional amounts", () => { + const duration: Duration = { + sign: "+", + years: 1.5, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 1.005, + }; + + strictEqual(Duration.toISO8601(duration), "P1.5YT1.005S"); + }); + }); +}); diff --git a/packages/http-server-javascript/test/scalar.test.ts b/packages/http-server-javascript/test/scalar.test.ts new file mode 100644 index 0000000000..4f346902fa --- /dev/null +++ b/packages/http-server-javascript/test/scalar.test.ts @@ -0,0 +1,266 @@ +import { ModelProperty, NoTarget, Program, Scalar } from "@typespec/compiler"; +import { BasicTestRunner, createTestRunner } from "@typespec/compiler/testing"; +import { deepStrictEqual, strictEqual } from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { getJsScalar } from "../src/common/scalar.js"; +import { createPathCursor, JsContext, Module } from "../src/ctx.js"; + +import { module as dateTimeModule } from "../generated-defs/helpers/datetime.js"; + +describe("scalar", () => { + let runner: BasicTestRunner; + + beforeEach(async () => { + runner = await createTestRunner(); + }); + + function createFakeModule(program: Program): [JsContext, Module] { + const module: Module = { + name: "example", + cursor: createPathCursor(), + + imports: [], + declarations: [], + }; + + // Min context + const ctx: JsContext = { + program, + rootModule: module, + } as JsContext; + + return [ctx, module]; + } + + async function getScalar(...names: string[]): Promise { + const { test } = (await runner.compile(` + model Example { + @test test: [${names.join(", ")}]; + } + `)) as { test: ModelProperty }; + + if (test.type.kind !== "Tuple") { + throw new Error("Expected tuple type"); + } + + if (!test.type.values.every((t) => t.kind === "Scalar")) { + throw new Error("Expected scalar types only"); + } + + return test.type.values as Scalar[]; + } + + it("has no-op encoding for string", async () => { + const [string] = await getScalar("TypeSpec.string"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, string, NoTarget); + + strictEqual(jsScalar.type, "string"); + strictEqual(jsScalar.getEncoding("default", string)?.encode("asdf"), "(asdf)"); + strictEqual(mod.imports.length, 0); + }); + + it("correctly encodes and decodes all numbers using default string encoding", async () => { + const [string, ...numbers] = await getScalar( + "string", + "float32", + "float64", + "int8", + "int16", + "int32", + "uint8", + "uint16", + "uint32", + ); + + const [ctx, mod] = createFakeModule(runner.program); + + for (const number of numbers) { + const jsScalar = getJsScalar(ctx, mod, number, NoTarget); + + strictEqual(jsScalar.type, "number"); + + const encoding = jsScalar.getEncoding("default", string); + + if (!encoding) { + throw new Error("Expected default encoding"); + } + + const encoded = encoding.encode("asdf"); + + strictEqual(encoded, "globalThis.String((asdf))"); + + const decoded = encoding.decode("asdf"); + + strictEqual(decoded, "globalThis.Number((asdf))"); + } + }); + + it("encodes and decodes types that coerce to bigint using default string encoding", async () => { + const [string, ...bigints] = await getScalar("string", "uint64", "int64", "integer"); + + const [ctx, mod] = createFakeModule(runner.program); + + for (const bigint of bigints) { + const jsScalar = getJsScalar(ctx, mod, bigint, NoTarget); + + strictEqual(jsScalar.type, "bigint"); + + const encoding = jsScalar.getEncoding("default", string); + + if (!encoding) { + throw new Error("Expected default encoding"); + } + + const encoded = encoding.encode("asdf"); + + strictEqual(encoded, "globalThis.String((asdf))"); + + const decoded = encoding.decode("asdf"); + + strictEqual(decoded, "globalThis.BigInt((asdf))"); + } + }); + + it("bytes base64 encoding", async () => { + const [string, bytes] = await getScalar("TypeSpec.string", "TypeSpec.bytes"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, bytes, NoTarget); + + strictEqual(jsScalar.type, "Uint8Array"); + + const encoding = jsScalar.getEncoding("base64", string); + + if (!encoding) { + throw new Error("Expected base64 encoding"); + } + + const encoded = encoding.encode("asdf"); + + strictEqual( + encoded, + "((asdf) instanceof globalThis.Buffer ? (asdf) : globalThis.Buffer.from((asdf))).toString('base64')", + ); + + const decoded = encoding.decode("asdf"); + + strictEqual(decoded, "globalThis.Buffer.from((asdf), 'base64')"); + }); + + it("bytes base64url encoding", async () => { + const [string, bytes] = await getScalar("TypeSpec.string", "TypeSpec.bytes"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, bytes, NoTarget); + + strictEqual(jsScalar.type, "Uint8Array"); + + const encoding = jsScalar.getEncoding("base64url", string); + + if (!encoding) { + throw new Error("Expected base64url encoding"); + } + + const encoded = encoding.encode("asdf"); + + strictEqual( + encoded, + "globalThis.encodeURIComponent((((asdf)) instanceof globalThis.Buffer ? ((asdf)) : globalThis.Buffer.from(((asdf)))).toString('base64'))", + ); + + const decoded = encoding.decode("asdf"); + + strictEqual( + decoded, + "globalThis.Buffer.from((globalThis.decodeURIComponent((asdf))), 'base64')", + ); + }); + + it("produces correct parse template for ISO8601 duration", async () => { + const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + + strictEqual(jsScalar.type, "Duration"); + strictEqual( + jsScalar.getEncoding("ISO8601", string)?.decode("asdf"), + "Duration.parseISO8601((asdf))", + ); + strictEqual(mod.imports[0].from, dateTimeModule); + deepStrictEqual(mod.imports[0].binder, ["Duration"]); + }); + + it("produces correct write template for ISO8601 duration", async () => { + const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + + strictEqual(jsScalar.type, "Duration"); + strictEqual( + jsScalar.getEncoding("ISO8601", string)?.encode("asdf"), + "Duration.toISO8601((asdf))", + ); + strictEqual(mod.imports[0].from, dateTimeModule); + deepStrictEqual(mod.imports[0].binder, ["Duration"]); + }); + + it("can parse and write ISO8601 duration", async () => { + const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + + strictEqual(jsScalar.type, "Duration"); + + const encoding = jsScalar.getEncoding("ISO8601", string); + + if (!encoding) { + throw new Error("Expected ISO8601 encoding"); + } + + const encoded = encoding.encode("duration"); + + strictEqual(encoded, "Duration.toISO8601((duration))"); + + const decoded = encoding.decode('"P1Y2M3DT4H5M6S"'); + + strictEqual(decoded, 'Duration.parseISO8601(("P1Y2M3DT4H5M6S"))'); + + strictEqual(mod.imports[0].from, dateTimeModule); + deepStrictEqual(mod.imports[0].binder, ["Duration"]); + }); + + it("allows default string encoding through via", async () => { + const [Duration, string] = await getScalar("duration", "string"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + + strictEqual(jsScalar.type, "Duration"); + + const encoding = jsScalar.getEncoding("default", string); + + if (!encoding) { + throw new Error("Expected default encoding"); + } + + const encoded = encoding.encode("duration"); + + strictEqual(encoded, "Duration.toISO8601(((duration)))"); + + const decoded = encoding.decode("duration"); + + strictEqual(decoded, "Duration.parseISO8601(((duration)))"); + }); +}); From b7bb2cd84ecf7759e7ff0f2de3dffd5269d2d79d Mon Sep 17 00:00:00 2001 From: Will Temple Date: Thu, 30 Jan 2025 11:10:17 -0500 Subject: [PATCH 3/6] Implement total seconds for Duration --- .../src/helpers/datetime.ts | 72 +++++++++++++++++++ .../test/datetime.test.ts | 42 +++++++++++ 2 files changed, 114 insertions(+) diff --git a/packages/http-server-javascript/src/helpers/datetime.ts b/packages/http-server-javascript/src/helpers/datetime.ts index 6090637a6d..0dd05919c5 100644 --- a/packages/http-server-javascript/src/helpers/datetime.ts +++ b/packages/http-server-javascript/src/helpers/datetime.ts @@ -140,6 +140,78 @@ export const Duration = Object.freeze({ return `${sign}P${years}${months}${weeks}${days}${time}`; }, + + /** + * Gets the total number of seconds in a duration. + * + * This method will throw an Error if the duration contains any years, months, weeks, or days, as those require a reference + * point to calculate the total number of seconds. + * + * WARNING: If the total number of seconds is larger than the maximum safe integer in JavaScript, this method will + * lose precision. @see Duration.totalSecondsBigInt for a BigInt alternative. + * + * @param duration - the duration to calculate the total number of seconds for + * @returns the total number of seconds in the duration + */ + totalSeconds(duration: Duration): number { + if ( + duration.years !== 0 || + duration.months !== 0 || + duration.weeks !== 0 || + duration.days !== 0 + ) { + throw new Error( + "Cannot calculate total seconds for a duration with years, months, weeks, or days.", + ); + } + + return ( + duration.seconds + + duration.minutes * 60 + + duration.hours * 60 * 60 + + duration.weeks * 7 * 24 * 60 * 60 + ); + }, + + /** + * Gets the total number of seconds in a duration. + * + * This method will throw an Error if the duration contains any years, months, weeks, or days, as those require a reference + * point to calculate the total number of seconds. It will also throw an error if any of the components are not integers. + * + * @param duration - the duration to calculate the total number of seconds for + * @returns the total number of seconds in the duration + */ + totalSecondsBigInt(duration: Duration): bigint { + if ( + duration.years !== 0 || + duration.months !== 0 || + duration.weeks !== 0 || + duration.days !== 0 + ) { + throw new Error( + "Cannot calculate total seconds for a duration with years, months, weeks, or days.", + ); + } + + if ( + !Number.isInteger(duration.seconds) || + !Number.isInteger(duration.minutes) || + !Number.isInteger(duration.hours) || + !Number.isInteger(duration.weeks) + ) { + throw new Error( + "Cannot calculate total seconds as a BigInt for a duration with non-integer components.", + ); + } + + return ( + BigInt(duration.seconds) + + BigInt(duration.minutes) * 60n + + BigInt(duration.hours) * 60n * 60n + + BigInt(duration.weeks) * 7n * 24n * 60n * 60n + ); + }, }); // #endregion diff --git a/packages/http-server-javascript/test/datetime.test.ts b/packages/http-server-javascript/test/datetime.test.ts index e3c674996d..64efb17963 100644 --- a/packages/http-server-javascript/test/datetime.test.ts +++ b/packages/http-server-javascript/test/datetime.test.ts @@ -180,5 +180,47 @@ describe("datetime", () => { strictEqual(Duration.toISO8601(duration), "P1.5YT1.005S"); }); + + it("computes total seconds in durations", () => { + const duration = Duration.parseISO8601("PT22H96M60S"); + + strictEqual(Duration.totalSeconds(duration), 22 * 60 * 60 + 96 * 60 + 60); + strictEqual(Duration.totalSecondsBigInt(duration), 22n * 60n * 60n + 96n * 60n + 60n); + }); + + it("computes total seconds in durations with fractional amounts", () => { + const duration = Duration.parseISO8601("PT1.5H22.005S"); + + strictEqual(Duration.totalSeconds(duration), 1.5 * 60 * 60 + 22 + 0.005); + }); + + it("does not allow total seconds for durations with years, months, weeks, or days", () => { + const durations = ["P1Y", "P1M", "P1W", "P1D"].map((iso) => Duration.parseISO8601(iso)); + + for (const duration of durations) { + throws(() => Duration.totalSeconds(duration), { + message: + "Cannot calculate total seconds for a duration with years, months, weeks, or days.", + }); + + throws(() => Duration.totalSecondsBigInt(duration), { + message: + "Cannot calculate total seconds for a duration with years, months, weeks, or days.", + }); + } + }); + + it("does not allow total seconds as bigint for durations with fractional amounts", () => { + const durations = ["PT1.5H", "PT1.5M", "PT1.5S", "PT1H1.5M", "PT1H1.5S", "PT1M1.5S"].map( + (iso) => Duration.parseISO8601(iso), + ); + + for (const duration of durations) { + throws(() => Duration.totalSecondsBigInt(duration), { + message: + "Cannot calculate total seconds as a BigInt for a duration with non-integer components.", + }); + } + }); }); }); From 2a2df21e13df964fac299761c59c92ab6b01ac2b Mon Sep 17 00:00:00 2001 From: Will Temple Date: Thu, 30 Jan 2025 11:54:07 -0500 Subject: [PATCH 4/6] Implement seconds encoding for duration --- .../generated-defs/helpers/datetime.ts | 90 +++++++++ .../src/common/scalar.ts | 86 ++++++-- .../src/helpers/datetime.ts | 18 ++ .../test/scalar.test.ts | 184 +++++++++++++----- 4 files changed, 311 insertions(+), 67 deletions(-) diff --git a/packages/http-server-javascript/generated-defs/helpers/datetime.ts b/packages/http-server-javascript/generated-defs/helpers/datetime.ts index 1ab75bd37a..bb163905a1 100644 --- a/packages/http-server-javascript/generated-defs/helpers/datetime.ts +++ b/packages/http-server-javascript/generated-defs/helpers/datetime.ts @@ -149,6 +149,96 @@ const lines = [ "", " return `${sign}P${years}${months}${weeks}${days}${time}`;", " },", + "", + " /**", + " * Gets the total number of seconds in a duration.", + " *", + " * This method will throw an Error if the duration contains any years, months, weeks, or days, as those require a reference", + " * point to calculate the total number of seconds.", + " *", + " * WARNING: If the total number of seconds is larger than the maximum safe integer in JavaScript, this method will", + " * lose precision. @see Duration.totalSecondsBigInt for a BigInt alternative.", + " *", + " * @param duration - the duration to calculate the total number of seconds for", + " * @returns the total number of seconds in the duration", + " */", + " totalSeconds(duration: Duration): number {", + " if (", + " duration.years !== 0 ||", + " duration.months !== 0 ||", + " duration.weeks !== 0 ||", + " duration.days !== 0", + " ) {", + " throw new Error(", + " \"Cannot calculate total seconds for a duration with years, months, weeks, or days.\",", + " );", + " }", + "", + " return (", + " duration.seconds +", + " duration.minutes * 60 +", + " duration.hours * 60 * 60 +", + " duration.weeks * 7 * 24 * 60 * 60", + " );", + " },", + "", + " /**", + " * Gets the total number of seconds in a duration.", + " *", + " * This method will throw an Error if the duration contains any years, months, weeks, or days, as those require a reference", + " * point to calculate the total number of seconds. It will also throw an error if any of the components are not integers.", + " *", + " * @param duration - the duration to calculate the total number of seconds for", + " * @returns the total number of seconds in the duration", + " */", + " totalSecondsBigInt(duration: Duration): bigint {", + " if (", + " duration.years !== 0 ||", + " duration.months !== 0 ||", + " duration.weeks !== 0 ||", + " duration.days !== 0", + " ) {", + " throw new Error(", + " \"Cannot calculate total seconds for a duration with years, months, weeks, or days.\",", + " );", + " }", + "", + " if (", + " !Number.isInteger(duration.seconds) ||", + " !Number.isInteger(duration.minutes) ||", + " !Number.isInteger(duration.hours) ||", + " !Number.isInteger(duration.weeks)", + " ) {", + " throw new Error(", + " \"Cannot calculate total seconds as a BigInt for a duration with non-integer components.\",", + " );", + " }", + "", + " return (", + " BigInt(duration.seconds) +", + " BigInt(duration.minutes) * 60n +", + " BigInt(duration.hours) * 60n * 60n +", + " BigInt(duration.weeks) * 7n * 24n * 60n * 60n", + " );", + " },", + "", + " /**", + " * Creates a duration from a total number of seconds.", + " *", + " * The result is not normalized, so it will only contain a seconds field.", + " */", + " fromTotalSeconds(seconds: number): Duration {", + " return {", + " sign: seconds < 0 ? \"-\" : \"+\",", + " years: 0,", + " months: 0,", + " weeks: 0,", + " days: 0,", + " hours: 0,", + " minutes: 0,", + " seconds: Math.abs(seconds),", + " };", + " },", "});", "", "// #endregion", diff --git a/packages/http-server-javascript/src/common/scalar.ts b/packages/http-server-javascript/src/common/scalar.ts index 2469584a61..acdfbcb87f 100644 --- a/packages/http-server-javascript/src/common/scalar.ts +++ b/packages/http-server-javascript/src/common/scalar.ts @@ -75,15 +75,12 @@ export type MaybeDependent = T | Dependent; /** * A definition of a scalar encoding. */ -export interface ScalarEncoding { - /** - * If set, the name of the encoding to use as a base for this encoding. - * - * This can be used to define an encoding that is a modification of another encoding, such as a URL-encoded version - * of a base64-encoded value, which depends on the base64 encoding. - */ - via?: string; +export type ScalarEncoding = ScalarEncodingTemplates | ScalarEncodingVia; +/** + * A definition of a scalar encoding with templates. + */ +export interface ScalarEncodingTemplates { /** * The template to use to encode the scalar. * @@ -99,6 +96,44 @@ export interface ScalarEncoding { decodeTemplate: MaybeDependent; } +export interface ScalarEncodingVia { + /** + * If set, the name of the encoding to use as a base for this encoding. + * + * This can be used to define an encoding that is a modification of another encoding, such as a URL-encoded version + * of a base64-encoded value, which depends on the base64 encoding. + */ + via: string; + + /** + * Optional encoding template, defaults to "{}" + */ + encodeTemplate?: MaybeDependent; + + /** + * Optional decoding template, defaults to "{}" + */ + decodeTemplate?: MaybeDependent; +} + +const DURATION_NUMBER_ENCODING: Dependent = (_, module) => { + module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); + + return { + encodeTemplate: "Duration.totalSeconds({})", + decodeTemplate: "Duration.fromSeconds({})", + }; +}; + +const DURATION_BIGINT_ENCODING: Dependent = (_, module) => { + module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); + + return { + encodeTemplate: "Duration.totalSecondsBigInt({})", + decodeTemplate: "Duration.fromSeconds(globalThis.Number({}))", + }; +}; + const TYPESPEC_DURATION: ScalarInfo = { type: function importDuration(_, module) { module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); @@ -109,8 +144,6 @@ const TYPESPEC_DURATION: ScalarInfo = { "TypeSpec.string": { default: { via: "iso8601", - encodeTemplate: "{}", - decodeTemplate: "{}", }, iso8601: function importDurationForEncode(_, module) { module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); @@ -120,6 +153,24 @@ const TYPESPEC_DURATION: ScalarInfo = { }; }, }, + ...Object.fromEntries( + ["int32", "uint32"].map((n) => [ + `TypeSpec.${n}`, + { + default: { via: "seconds" }, + seconds: DURATION_NUMBER_ENCODING, + }, + ]), + ), + ...Object.fromEntries( + ["int64", "uint64"].map((n) => [ + `TypeSpec.${n}`, + { + default: { via: "seconds" }, + seconds: DURATION_BIGINT_ENCODING, + }, + ]), + ), }, defaultEncodings: { byMimeType: { @@ -205,6 +256,9 @@ const SCALARS = new Map([ "TypeSpec.string", { type: "string", + // This little no-op encoding makes it so that we can attempt to encode string to itself infallibly and it will + // do nothing. We therefore don't need to redundantly describe HTTP encodings for query, header, etc. because + // they rely on the ["TypeSpec.string", "default"] encoding in the absence of a more specific encoding. encodings: { "TypeSpec.string": { default: { encodeTemplate: "{}", decodeTemplate: "{}" } } }, isJsonCompatible: true, }, @@ -336,7 +390,7 @@ function createJsScalar( _decodeTemplate ??= typeof encodingSpec.decodeTemplate === "function" ? encodingSpec.decodeTemplate(ctx, module) - : encodingSpec.decodeTemplate; + : (encodingSpec.decodeTemplate ?? "{}"); subject = `(${subject})`; @@ -344,7 +398,7 @@ function createJsScalar( subject = _decodeTemplate.replaceAll("{}", subject); - if (encodingSpec.via) { + if (isVia(encodingSpec)) { const via = self.getEncoding(encodingSpec.via, target); if (via === undefined) { @@ -361,13 +415,13 @@ function createJsScalar( _encodeTemplate ??= typeof encodingSpec.encodeTemplate === "function" ? encodingSpec.encodeTemplate(ctx, module) - : encodingSpec.encodeTemplate; + : (encodingSpec.encodeTemplate ?? "{}"); subject = `(${subject})`; // If we have a via, encode to it first - if (encodingSpec.via) { + if (isVia(encodingSpec)) { const via = self.getEncoding(encodingSpec.via, target); if (via === undefined) { @@ -452,6 +506,10 @@ function createJsScalar( } } +function isVia(encoding: ScalarEncoding): encoding is ScalarEncodingVia { + return "via" in encoding; +} + const REPORTED_UNRECOGNIZED_SCALARS = new WeakMap>(); export function reportUnrecognizedScalar( diff --git a/packages/http-server-javascript/src/helpers/datetime.ts b/packages/http-server-javascript/src/helpers/datetime.ts index 0dd05919c5..3fbda63816 100644 --- a/packages/http-server-javascript/src/helpers/datetime.ts +++ b/packages/http-server-javascript/src/helpers/datetime.ts @@ -212,6 +212,24 @@ export const Duration = Object.freeze({ BigInt(duration.weeks) * 7n * 24n * 60n * 60n ); }, + + /** + * Creates a duration from a total number of seconds. + * + * The result is not normalized, so it will only contain a seconds field. + */ + fromTotalSeconds(seconds: number): Duration { + return { + sign: seconds < 0 ? "-" : "+", + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: Math.abs(seconds), + }; + }, }); // #endregion diff --git a/packages/http-server-javascript/test/scalar.test.ts b/packages/http-server-javascript/test/scalar.test.ts index 4f346902fa..667d648b27 100644 --- a/packages/http-server-javascript/test/scalar.test.ts +++ b/packages/http-server-javascript/test/scalar.test.ts @@ -181,86 +181,164 @@ describe("scalar", () => { ); }); - it("produces correct parse template for ISO8601 duration", async () => { - const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); + describe("duration", () => { + it("produces correct parse template for ISO8601 duration", async () => { + const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); - const [ctx, mod] = createFakeModule(runner.program); + const [ctx, mod] = createFakeModule(runner.program); - const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); - strictEqual(jsScalar.type, "Duration"); - strictEqual( - jsScalar.getEncoding("ISO8601", string)?.decode("asdf"), - "Duration.parseISO8601((asdf))", - ); - strictEqual(mod.imports[0].from, dateTimeModule); - deepStrictEqual(mod.imports[0].binder, ["Duration"]); - }); + strictEqual(jsScalar.type, "Duration"); + strictEqual( + jsScalar.getEncoding("ISO8601", string)?.decode("asdf"), + "Duration.parseISO8601((asdf))", + ); + strictEqual(mod.imports[0].from, dateTimeModule); + deepStrictEqual(mod.imports[0].binder, ["Duration"]); + }); - it("produces correct write template for ISO8601 duration", async () => { - const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); + it("produces correct write template for ISO8601 duration", async () => { + const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); - const [ctx, mod] = createFakeModule(runner.program); + const [ctx, mod] = createFakeModule(runner.program); - const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); - strictEqual(jsScalar.type, "Duration"); - strictEqual( - jsScalar.getEncoding("ISO8601", string)?.encode("asdf"), - "Duration.toISO8601((asdf))", - ); - strictEqual(mod.imports[0].from, dateTimeModule); - deepStrictEqual(mod.imports[0].binder, ["Duration"]); - }); + strictEqual(jsScalar.type, "Duration"); + strictEqual( + jsScalar.getEncoding("ISO8601", string)?.encode("asdf"), + "Duration.toISO8601((asdf))", + ); + strictEqual(mod.imports[0].from, dateTimeModule); + deepStrictEqual(mod.imports[0].binder, ["Duration"]); + }); - it("can parse and write ISO8601 duration", async () => { - const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); + it("can parse and write ISO8601 duration", async () => { + const [Duration, string] = await getScalar("TypeSpec.duration", "TypeSpec.string"); - const [ctx, mod] = createFakeModule(runner.program); + const [ctx, mod] = createFakeModule(runner.program); - const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); - strictEqual(jsScalar.type, "Duration"); + strictEqual(jsScalar.type, "Duration"); - const encoding = jsScalar.getEncoding("ISO8601", string); + const encoding = jsScalar.getEncoding("ISO8601", string); - if (!encoding) { - throw new Error("Expected ISO8601 encoding"); - } + if (!encoding) { + throw new Error("Expected ISO8601 encoding"); + } - const encoded = encoding.encode("duration"); + const encoded = encoding.encode("duration"); - strictEqual(encoded, "Duration.toISO8601((duration))"); + strictEqual(encoded, "Duration.toISO8601((duration))"); - const decoded = encoding.decode('"P1Y2M3DT4H5M6S"'); + const decoded = encoding.decode('"P1Y2M3DT4H5M6S"'); - strictEqual(decoded, 'Duration.parseISO8601(("P1Y2M3DT4H5M6S"))'); + strictEqual(decoded, 'Duration.parseISO8601(("P1Y2M3DT4H5M6S"))'); - strictEqual(mod.imports[0].from, dateTimeModule); - deepStrictEqual(mod.imports[0].binder, ["Duration"]); - }); + strictEqual(mod.imports[0].from, dateTimeModule); + deepStrictEqual(mod.imports[0].binder, ["Duration"]); + }); - it("allows default string encoding through via", async () => { - const [Duration, string] = await getScalar("duration", "string"); + it("allows default string encoding through via", async () => { + const [Duration, string] = await getScalar("duration", "string"); - const [ctx, mod] = createFakeModule(runner.program); + const [ctx, mod] = createFakeModule(runner.program); - const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); - strictEqual(jsScalar.type, "Duration"); + strictEqual(jsScalar.type, "Duration"); - const encoding = jsScalar.getEncoding("default", string); + const encoding = jsScalar.getEncoding("default", string); - if (!encoding) { - throw new Error("Expected default encoding"); - } + if (!encoding) { + throw new Error("Expected default encoding"); + } + + const encoded = encoding.encode("duration"); + + strictEqual(encoded, "Duration.toISO8601(((duration)))"); + + const decoded = encoding.decode("duration"); + + strictEqual(decoded, "Duration.parseISO8601(((duration)))"); + }); + + it("allows encoding seconds to number types", async () => { + const [Duration, int32, uint32] = await getScalar("duration", "int32", "uint32"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + + strictEqual(jsScalar.type, "Duration"); + + const encodingInt32 = jsScalar.getEncoding("seconds", int32); + + if (!encodingInt32) { + throw new Error("Expected seconds encoding int32"); + } + + const encodedInt32 = encodingInt32.encode("duration"); + + strictEqual(encodedInt32, "Duration.totalSeconds((duration))"); + + const decodedInt32 = encodingInt32.decode("duration"); + + strictEqual(decodedInt32, "Duration.fromSeconds((duration))"); + + const encodingUint32 = jsScalar.getEncoding("seconds", uint32); + + if (!encodingUint32) { + throw new Error("Expected seconds encoding uint32"); + } + + const encodedUint32 = encodingUint32.encode("duration"); + + strictEqual(encodedUint32, "Duration.totalSeconds((duration))"); + + const decodedUint32 = encodingUint32.decode("duration"); + + strictEqual(decodedUint32, "Duration.fromSeconds((duration))"); + }); + + it("allows encoding seconds to bigint types", async () => { + const [Duration, int64, uint64] = await getScalar("duration", "int64", "uint64"); + + const [ctx, mod] = createFakeModule(runner.program); + + const jsScalar = getJsScalar(ctx, mod, Duration, NoTarget); + + strictEqual(jsScalar.type, "Duration"); + + const encodingInt64 = jsScalar.getEncoding("seconds", int64); + + if (!encodingInt64) { + throw new Error("Expected seconds encoding int64"); + } + + const encodedInt64 = encodingInt64.encode("duration"); + + strictEqual(encodedInt64, "Duration.totalSecondsBigInt((duration))"); + + const decodedInt64 = encodingInt64.decode("duration"); + + strictEqual(decodedInt64, "Duration.fromSeconds(globalThis.Number((duration)))"); + + const encodingUint64 = jsScalar.getEncoding("seconds", uint64); + + if (!encodingUint64) { + throw new Error("Expected seconds encoding uint64"); + } - const encoded = encoding.encode("duration"); + const encodedUint64 = encodingUint64.encode("duration"); - strictEqual(encoded, "Duration.toISO8601(((duration)))"); + strictEqual(encodedUint64, "Duration.totalSecondsBigInt((duration))"); - const decoded = encoding.decode("duration"); + const decodedUint64 = encodingUint64.decode("duration"); - strictEqual(decoded, "Duration.parseISO8601(((duration)))"); + strictEqual(decodedUint64, "Duration.fromSeconds(globalThis.Number((duration)))"); + }); }); }); From 47fcf22187a358763acc08e339b7c66bea47f225 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Thu, 30 Jan 2025 12:01:06 -0500 Subject: [PATCH 5/6] Clean up some of the encoder logic --- .../http-server-javascript/src/common/scalar.ts | 6 +++++- .../src/common/serialization/json.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/http-server-javascript/src/common/scalar.ts b/packages/http-server-javascript/src/common/scalar.ts index acdfbcb87f..7e78a1ac40 100644 --- a/packages/http-server-javascript/src/common/scalar.ts +++ b/packages/http-server-javascript/src/common/scalar.ts @@ -579,7 +579,7 @@ export interface Encoder { export interface JsScalar { readonly type: string; - readonly scalar?: Scalar; + readonly scalar: Scalar | "unknown"; getEncoding(encoding: string, target: Scalar): Encoder | undefined; @@ -601,8 +601,12 @@ const DEFAULT_STRING_ENCODER_RAW: Omit = { }, }; +/** + * A JsScalar value that represents an unknown scalar. + */ export const JSSCALAR_UNKNOWN: JsScalar = { type: "unknown", + scalar: "unknown", getEncoding: () => undefined, getDefaultMimeEncoding: () => undefined, http: { diff --git a/packages/http-server-javascript/src/common/serialization/json.ts b/packages/http-server-javascript/src/common/serialization/json.ts index e3803d928c..cdbd7953ee 100644 --- a/packages/http-server-javascript/src/common/serialization/json.ts +++ b/packages/http-server-javascript/src/common/serialization/json.ts @@ -165,11 +165,10 @@ function* emitToJson( const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type); if (scalarEncoder) { - // Scalar must be defined here because we resolved an encoding. It can only be undefined when it comes - // from an `unknown` rendering, which cannot appear in a resolved encoding. expr = transposeExpressionToJson( ctx, - scalarEncoder.target.scalar!, + // Assertion: scalarEncoder.target.scalar is defined because we resolved an encoder. + scalarEncoder.target.scalar as Scalar, scalarEncoder.encode(expr), module, ); @@ -267,7 +266,8 @@ function transposeExpressionToJson( if (encoder.target.isJsonCompatible || !encoder.target.scalar) { return encoded; } else { - return transposeExpressionToJson(ctx, encoder.target.scalar, encoded, module); + // Assertion: encoder.target.scalar is a scalar because "unknown" is JSON compatible. + return transposeExpressionToJson(ctx, encoder.target.scalar as Scalar, encoded, module); } case "Union": if (!requiresJsonSerialization(ctx, module, type)) { @@ -401,7 +401,8 @@ function* emitFromJson( if (scalarEncoder) { expr = transposeExpressionFromJson( ctx, - scalarEncoder.target.scalar!, + // Assertion: scalarEncoder.target.scalar is defined because we resolved an encoder. + scalarEncoder.target.scalar as Scalar, scalarEncoder.decode(expr), module, ); @@ -416,7 +417,7 @@ function* emitFromJson( }, }); - // We treat this as unknown from here on out. The encoding was not decipher + // We treat this as unknown from here on out. The encoding was not deciphered. } } else { expr = transposeExpressionFromJson(ctx, property.type, expr, module); @@ -505,7 +506,8 @@ function transposeExpressionFromJson( if (encoder.target.isJsonCompatible || !encoder.target.scalar) { return decoded; } else { - return transposeExpressionFromJson(ctx, encoder.target.scalar, decoded, module); + // Assertion: encoder.target.scalar is a scalar because "unknown" is JSON compatible. + return transposeExpressionFromJson(ctx, encoder.target.scalar as Scalar, decoded, module); } case "Union": if (!requiresJsonSerialization(ctx, module, type)) { From 4ebbb9ba5f952c04a58ea19b41f9274c1f17ef03 Mon Sep 17 00:00:00 2001 From: Will Temple Date: Thu, 30 Jan 2025 13:31:33 -0500 Subject: [PATCH 6/6] chronus/cspell --- .../witemple-msft-hsj-scalars-2025-0-30-13-29-18.md | 7 +++++++ packages/http-server-javascript/src/common/scalar.ts | 12 ++++++------ .../src/common/serialization/json.ts | 6 +++--- 3 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 .chronus/changes/witemple-msft-hsj-scalars-2025-0-30-13-29-18.md diff --git a/.chronus/changes/witemple-msft-hsj-scalars-2025-0-30-13-29-18.md b/.chronus/changes/witemple-msft-hsj-scalars-2025-0-30-13-29-18.md new file mode 100644 index 0000000000..d375746719 --- /dev/null +++ b/.chronus/changes/witemple-msft-hsj-scalars-2025-0-30-13-29-18.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/http-server-javascript" +--- + +Support encoding/decoding scalar types. \ No newline at end of file diff --git a/packages/http-server-javascript/src/common/scalar.ts b/packages/http-server-javascript/src/common/scalar.ts index 7e78a1ac40..4e031707fd 100644 --- a/packages/http-server-javascript/src/common/scalar.ts +++ b/packages/http-server-javascript/src/common/scalar.ts @@ -604,7 +604,7 @@ const DEFAULT_STRING_ENCODER_RAW: Omit = { /** * A JsScalar value that represents an unknown scalar. */ -export const JSSCALAR_UNKNOWN: JsScalar = { +export const JS_SCALAR_UNKNOWN: JsScalar = { type: "unknown", scalar: "unknown", getEncoding: () => undefined, @@ -612,25 +612,25 @@ export const JSSCALAR_UNKNOWN: JsScalar = { http: { get header() { return { - target: JSSCALAR_UNKNOWN, + target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, get query() { return { - target: JSSCALAR_UNKNOWN, + target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, get cookie() { return { - target: JSSCALAR_UNKNOWN, + target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, get path() { return { - target: JSSCALAR_UNKNOWN, + target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, @@ -672,5 +672,5 @@ export function getJsScalar( reportUnrecognizedScalar(ctx, scalar, diagnosticTarget); - return JSSCALAR_UNKNOWN; + return JS_SCALAR_UNKNOWN; } diff --git a/packages/http-server-javascript/src/common/serialization/json.ts b/packages/http-server-javascript/src/common/serialization/json.ts index cdbd7953ee..e6d4ed897b 100644 --- a/packages/http-server-javascript/src/common/serialization/json.ts +++ b/packages/http-server-javascript/src/common/serialization/json.ts @@ -28,7 +28,7 @@ import { indent } from "../../util/iter.js"; import { keywordSafe } from "../../util/keywords.js"; import { getFullyQualifiedTypeName } from "../../util/name.js"; import { emitTypeReference, escapeUnsafeChars } from "../reference.js"; -import { Encoder, JSSCALAR_UNKNOWN, JsScalar, getJsScalar } from "../scalar.js"; +import { Encoder, JS_SCALAR_UNKNOWN, JsScalar, getJsScalar } from "../scalar.js"; import { SerializableType, SerializationContext, requireSerialization } from "./index.js"; /** @@ -347,7 +347,7 @@ function getScalarEncoder(ctx: SerializationContext, type: Scalar, scalar: JsSca }); encoder = { - target: JSSCALAR_UNKNOWN, + target: JS_SCALAR_UNKNOWN, encode: (expr) => expr, decode: (expr) => expr, }; @@ -357,7 +357,7 @@ function getScalarEncoder(ctx: SerializationContext, type: Scalar, scalar: JsSca } else { // No encoding specified, use the default content type encoding for json encoder = scalar.getDefaultMimeEncoding("application/json") ?? { - target: JSSCALAR_UNKNOWN, + target: JS_SCALAR_UNKNOWN, encode: (expr) => expr, decode: (expr) => expr, };