UNPKG

@typespec/http-server-js

Version:

TypeSpec HTTP server code generator for JavaScript

1,168 lines (1,028 loc) 34.3 kB
// Copyright (c) Microsoft Corporation // Licensed under the MIT license. import { DiagnosticTarget, EncodeData, NoTarget, Program, Scalar } from "@typespec/compiler"; import { JsContext, Module } from "../ctx.js"; import { reportDiagnostic } from "../lib.js"; import { parseCase } from "../util/case.js"; import { getFullyQualifiedTypeName } from "../util/name.js"; import { HttpOperationParameter } from "@typespec/http"; import { UnreachableError } from "../util/error.js"; import { module as dateTimeModule } from "../../generated-defs/helpers/datetime.js"; import { module as temporalNativeHelpers } from "../../generated-defs/helpers/temporal/native.js"; import { module as temporalPolyfillHelpers } from "../../generated-defs/helpers/temporal/polyfill.js"; import { emitDocumentation } from "./documentation.js"; /** * A specification of a TypeSpec scalar type. */ export interface ScalarInfo { /** * The TypeScript type that represents the scalar, or a function if the scalar requires a representation * that is not built-in. */ type: MaybeDependent<string>; /** * A map of supported encodings for the scalar. */ encodings?: { [target: string]: { /** * The default encoding for the target. */ default?: MaybeDependent<ScalarEncoding>; /** * The encoding for the scalar when encoded using a particular method. */ [encoding: string]: MaybeDependent<ScalarEncoding> | undefined; }; }; /** * A map of default encodings for the scalar. */ defaultEncodings?: { /** * 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; } /** * A function that resolves a value dependent on the context and module it's requested from. */ export interface Dependent<T> { (ctx: JsContext, module: Module): T; } /** * A value that might be dependent. */ export type MaybeDependent<T> = T | Dependent<T>; /** * A definition of a scalar encoding. */ export type ScalarEncoding = ScalarEncodingTemplates | ScalarEncodingVia; /** * A definition of a scalar encoding with templates. */ export interface ScalarEncodingTemplates { /** * 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<string>; /** * 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<string>; } 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<string>; /** * Optional decoding template, defaults to "{}" */ decodeTemplate?: MaybeDependent<string>; } /** * Resolves the encoding of Duration values to a number of seconds. */ const DURATION_NUMBER_ENCODING: Dependent<ScalarEncoding> = (_, module) => { module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); return { encodeTemplate: "Duration.totalSeconds({})", decodeTemplate: "Duration.fromTotalSeconds({})", }; }; /** * Resolves the encoding of Duration values to a BigInt number of seconds. */ const DURATION_BIGINT_ENCODING: Dependent<ScalarEncoding> = (_, module) => { module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); return { encodeTemplate: "Duration.totalSecondsBigInt({})", decodeTemplate: "Duration.fromTotalSeconds(globalThis.Number({}))", }; }; /** * Resolves the encoding of Duration values to a BigDecimal number of seconds. */ const DURATION_BIGDECIMAL_ENCODING: Dependent<ScalarEncoding> = (_, module) => { module.imports.push( { from: dateTimeModule, binder: ["Duration"] }, { from: "decimal.js", binder: ["Decimal"] }, ); return { encodeTemplate: "new Decimal(Duration.totalSeconds({}).toString())", decodeTemplate: "Duration.fromSeconds(({}).toNumber())", }; }; const DURATION: Dependent<ScalarInfo> = (ctx, module) => { const mode = ctx.options.datetime ?? "temporal-polyfill"; if (mode === "temporal-polyfill") { module.imports.push({ from: "temporal-polyfill", binder: ["Temporal"] }); } const isTemporal = mode === "temporal" || mode === "temporal-polyfill"; const temporalRef = mode === "temporal-polyfill" ? "Temporal" : "globalThis.Temporal"; if (!isTemporal) return DURATION_CUSTOM; const isPolyfill = mode === "temporal-polyfill"; return { type: "Temporal.Duration", isJsonCompatible: false, encodings: { "TypeSpec.string": { default: { via: "iso8601" }, iso8601: { encodeTemplate: `({}).toString()`, decodeTemplate: `${temporalRef}.Duration.from({})`, }, }, ...Object.fromEntries( ["int32", "uint32", "float32", "float64"].map((n) => [ `TypeSpec.${n}`, { default: { via: "seconds" }, seconds: { encodeTemplate: (_, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`durationTotalSeconds`], }); return `durationTotalSeconds({})`; }, decodeTemplate: `${temporalRef}.Duration.from({ seconds: {} })`, }, }, ]), ), ...Object.fromEntries( ["int64", "uint64", "integer"].map((n) => [ `TypeSpec.${n}`, { default: { via: "seconds" }, seconds: { encodeTemplate: (_, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`durationTotalSecondsBigInt`], }); return `durationTotalSecondsBigInt({})`; }, decodeTemplate: `${temporalRef}.Duration.from({ seconds: globalThis.Number({}) })`, }, }, ]), ), "TypeSpec.float": { default: { via: "seconds" }, seconds: { encodeTemplate: (_, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`durationTotalSecondsBigInt`], }); return `new Decimal(durationTotalSecondsBigInt({}).toString())`; }, decodeTemplate: `${temporalRef}.Duration.from({ seconds: ({}).toNumber() })`, }, }, }, defaultEncodings: { byMimeType: { "application/json": ["TypeSpec.string", "iso8601"], }, }, }; }; const DURATION_CUSTOM: ScalarInfo = { type: function importDuration(_, module) { module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); return "Duration"; }, encodings: { "TypeSpec.string": { default: { via: "iso8601", }, iso8601: function importDurationForEncode(_, module) { module.imports.push({ from: dateTimeModule, binder: ["Duration"] }); return { encodeTemplate: "Duration.toISO8601({})", decodeTemplate: "Duration.parseISO8601({})", }; }, }, ...Object.fromEntries( ["int32", "uint32", "float32", "float64"].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, }, ]), ), "TypeSpec.float": { default: { via: "seconds" }, seconds: DURATION_BIGDECIMAL_ENCODING, }, }, 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, }; const BIGDECIMAL: ScalarInfo = { type(_, module) { module.imports.push({ from: "decimal.js", binder: ["Decimal"] }); return "Decimal"; }, encodings: { "TypeSpec.string": { default: { encodeTemplate: "({}).toString()", decodeTemplate: "new Decimal({})", }, }, }, isJsonCompatible: false, }; const BIGINT: ScalarInfo = { type: "bigint", encodings: { "TypeSpec.string": { default: { encodeTemplate: "globalThis.String({})", decodeTemplate: "globalThis.BigInt({})", }, }, "TypeSpec.safeint": { lossy: { encodeTemplate: "globalThis.Number({})", decodeTemplate: "globalThis.BigInt({})", }, }, }, defaultEncodings: { byMimeType: { "application/json": ["TypeSpec.safeint", "lossy"], }, }, isJsonCompatible: false, }; /** * 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<string, MaybeDependent<ScalarInfo>>([ [ "TypeSpec.bytes", { type: "Uint8Array", encodings: { "TypeSpec.string": { 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", // 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, }, ], ["TypeSpec.float32", NUMBER], ["TypeSpec.float64", NUMBER], ["TypeSpec.uint64", BIGINT], ["TypeSpec.uint32", NUMBER], ["TypeSpec.uint16", NUMBER], ["TypeSpec.uint8", NUMBER], ["TypeSpec.int64", BIGINT], ["TypeSpec.int32", NUMBER], ["TypeSpec.int16", NUMBER], ["TypeSpec.int8", NUMBER], ["TypeSpec.safeint", NUMBER], ["TypeSpec.numeric", BIGDECIMAL], ["TypeSpec.float", BIGDECIMAL], ["TypeSpec.decimal", BIGDECIMAL], ["TypeSpec.decimal128", BIGDECIMAL], ["TypeSpec.integer", BIGINT], ["TypeSpec.plainDate", dateTime("plainDate")], ["TypeSpec.plainTime", dateTime("plainTime")], ["TypeSpec.utcDateTime", dateTime("utcDateTime")], ["TypeSpec.offsetDateTime", dateTime("offsetDateTime")], [ "TypeSpec.unixTimestamp32", { type: "number", encodings: { "TypeSpec.string": { default: { encodeTemplate: "globalThis.String({})", decodeTemplate: "globalThis.Number({})", }, }, "TypeSpec.int32": { default: { via: "unixTimestamp" }, unixTimestamp: { encodeTemplate: "{}", decodeTemplate: "{}", }, }, "TypeSpec.int64": { default: { via: "unixTimestamp" }, unixTimestamp: { encodeTemplate: "globalThis.BigInt({})", decodeTemplate: "globalThis.Number({})", }, }, }, isJsonCompatible: true, }, ], ["TypeSpec.duration", DURATION], ]); /** * Datetime types that support dynamic construction. */ type DateTimeType = "plainDate" | "plainTime" | "utcDateTime" | "offsetDateTime"; /** * Gets the DateTime Scalar specification for a given date time type. */ function dateTime(t: DateTimeType): Dependent<ScalarInfo> { return (ctx, module): ScalarInfo => { const mode = ctx.options.datetime ?? "temporal-polyfill"; if (mode === "temporal-polyfill") { module.imports.push({ from: "temporal-polyfill", binder: ["Temporal"] }); } const isTemporal = mode === "temporal" || mode === "temporal-polyfill"; const temporalRef = mode === "temporal-polyfill" ? "Temporal" : "globalThis.Temporal"; let type: string; switch (t) { case "plainDate": type = isTemporal ? "Temporal.PlainDate" : "Date"; break; case "plainTime": type = isTemporal ? "Temporal.PlainTime" : "Date"; break; case "utcDateTime": type = isTemporal ? "Temporal.Instant" : "Date"; break; case "offsetDateTime": type = isTemporal ? "Temporal.ZonedDateTime" : "Date"; break; default: void (t satisfies never); throw new UnreachableError(`Unknown datetime type: ${t}`); } return { type, isJsonCompatible: false, encodings: isTemporal ? TEMPORAL_ENCODERS(temporalRef, mode === "temporal-polyfill")[t] : LEGACY_DATETIME_ENCODER, defaultEncodings: { byMimeType: { "application/json": ["TypeSpec.string", "rfc3339"], }, http: { header: ["TypeSpec.string", "rfc7231"], query: ["TypeSpec.string", "rfc3339"], cookie: ["TypeSpec.string", "rfc7231"], path: ["TypeSpec.string", "rfc3339"], }, }, }; }; } const TEMPORAL_ENCODERS = ( temporal: string, isPolyfill: boolean, ): Record<DateTimeType, ScalarInfo["encodings"]> => { return { plainDate: { "TypeSpec.string": { default: { via: "iso8601" }, rfc3339: { via: "iso8601" }, iso8601: { encodeTemplate: "({}).toString()", decodeTemplate: `${temporal}.PlainDate.from({})`, }, }, }, plainTime: { "TypeSpec.string": { default: { via: "iso8601" }, rfc3339: { via: "iso8601" }, iso8601: { encodeTemplate: "({}).toString()", decodeTemplate: `${temporal}.PlainTime.from({})`, }, }, }, // Temporal.Instant utcDateTime: { "TypeSpec.string": { default: { via: "iso8601" }, rfc3339: { via: "iso8601" }, iso8601: { encodeTemplate: "({}).toString()", decodeTemplate: `${temporal}.Instant.from({})`, }, "http-date": { via: "rfc7231" }, rfc7231: { encodeTemplate: (ctx, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`formatHttpDate`], }); return `formatHttpDate({})`; }, decodeTemplate: (ctx, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`parseHttpDate`], }); return `parseHttpDate({})`; }, }, }, "TypeSpec.int32": { default: { via: "unixTimestamp" }, unixTimestamp: { encodeTemplate: "globalThis.Math.floor(({}).epochMilliseconds / 1000)", decodeTemplate: `${temporal}.Instant.fromEpochMilliseconds({} * 1000)`, }, }, "TypeSpec.int64": { default: { via: "unixTimestamp" }, unixTimestamp: { encodeTemplate: "({}).epochNanoseconds / 1_000_000_000n", decodeTemplate: `${temporal}.Instant.fromEpochNanoseconds({} * 1_000_000_000n)`, }, }, }, // Temporal.ZonedDateTime offsetDateTime: { "TypeSpec.string": { default: { via: "iso8601" }, rfc3339: { via: "iso8601" }, iso8601: { encodeTemplate: "({}).toString()", decodeTemplate: `${temporal}.ZonedDateTime.from({})`, }, "http-date": { via: "rfc7231" }, rfc7231: { encodeTemplate: (ctx, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`formatHttpDate`], }); return `formatHttpDate(({}).toInstant())`; }, decodeTemplate: (ctx, module) => { module.imports.push({ from: isPolyfill ? temporalPolyfillHelpers : temporalNativeHelpers, binder: [`parseHttpDate`], }); // HTTP dates are always GMT a.k.a. UTC return `parseHttpDate({}).toZonedDateTimeISO("UTC")`; }, }, }, }, }; }; /** * Encoding and decoding for legacy JS Date. */ const LEGACY_DATETIME_ENCODER: ScalarInfo["encodings"] = { "TypeSpec.string": { default: { via: "iso8601", }, iso8601: { encodeTemplate: "({}).toISOString()", decodeTemplate: "new globalThis.Date({})", }, rfc3339: { via: "iso8601", }, rfc7231: { encodeTemplate: "({}).toUTCString()", decodeTemplate: "new globalThis.Date({})", }, "http-date": { via: "rfc7231", }, }, "TypeSpec.int32": { default: { via: "unixTimestamp" }, unixTimestamp: { encodeTemplate: "globalThis.Math.floor(({}).getTime() / 1000)", decodeTemplate: `new globalThis.Date({} * 1000)`, }, }, "TypeSpec.int64": { default: { via: "unixTimestamp" }, unixTimestamp: { encodeTemplate: "globalThis.BigInt(({}).getTime()) / 1000n", decodeTemplate: `new globalThis.Date(globalThis.Number({}) * 1000)`, }, }, }; /** * Emits a declaration for a scalar type. * * 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, module: Module): Iterable<string> { const jsScalar = getJsScalar(ctx, module, scalar, scalar); const name = parseCase(scalar.name).pascalCase; yield* emitDocumentation(ctx, scalar); yield `export type ${name} = ${jsScalar.type};`; } /** * Helper function template that makes any type T computable sensitive to the JsContext and module it is referenced from. */ interface Contextualized<T> { (ctx: JsContext, module: Module): T; } /** * The store of scalars for a given program. */ type ScalarStore = Map<Scalar, Contextualized<JsScalar>>; /** * The store of all scalars known to the emitter in all active Programs. */ const __JS_SCALARS_MAP = new WeakMap<Program, ScalarStore>(); /** * Gets the scalar store for a given program. */ function getScalarStore(ctx: JsContext): ScalarStore { let scalars = __JS_SCALARS_MAP.get(ctx.program); if (scalars === undefined) { scalars = createScalarStore(ctx); __JS_SCALARS_MAP.set(ctx.program, scalars); } return scalars; } /** * Initializes a scalar store for a given program. */ function createScalarStore(ctx: JsContext): ScalarStore { const m = new Map<Scalar, Contextualized<JsScalar>>(); for (const [scalarName, scalarInfo] of SCALARS) { const [scalar, diagnostics] = ctx.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(ctx.program, scalar, scalarInfo, m)); } return m; } /** * Binds a ScalarInfo specification to a JsScalar. * * @param program - The program that contains the scalar. * @param scalar - The scalar to bind. * @param _scalarInfo - The scalar information spec to bind. * @param store - The scalar store to use for the scalar. * @returns a function that takes a JsContext and Module and returns a JsScalar. */ function createJsScalar( program: Program, scalar: Scalar, _scalarInfo: MaybeDependent<ScalarInfo>, store: ScalarStore, ): Contextualized<JsScalar> { return (ctx, module) => { const scalarInfo = typeof _scalarInfo === "function" ? _scalarInfo(ctx, module) : _scalarInfo; const _http: { [K in HttpOperationParameter["type"]]?: Encoder } = {}; let _type: string | undefined = undefined; const self = { get type() { return (_type ??= typeof scalarInfo.type === "function" ? scalarInfo.type(ctx, module) : scalarInfo.type); }, scalar, getEncoding(encodeDataOrString: EncodeData | string, target?: Scalar): Encoder | undefined { let encoding: string = "default"; if (typeof encodeDataOrString === "string") { encoding = encodeDataOrString; target = target!; } else { encoding = encodeDataOrString.encoding ?? "default"; target = encodeDataOrString.type; } const encodingTable = scalarInfo.encodings?.[getFullyQualifiedTypeName(target)]; let encodingSpec = encodingTable?.[encoding] ?? encodingTable?.[encoding.toLowerCase()]; if (encodingSpec === undefined) { return undefined; } encodingSpec = typeof encodingSpec === "function" ? encodingSpec(ctx, module) : encodingSpec; let _target: JsScalar | undefined = undefined; let _decodeTemplate: string | undefined = undefined; let _encodeTemplate: string | undefined = undefined; return { get target() { return (_target ??= 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 (isVia(encodingSpec)) { 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 (isVia(encodingSpec)) { 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; /** * Helper to get the HTTP encoders for the scalar. */ 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; } }; } /** * Returns `true` if the encoding is provided `via` another encoding. False otherwise. */ function isVia(encoding: ScalarEncoding): encoding is ScalarEncodingVia { return "via" in encoding; } /** Map to ensure we don't report the same unrecognized scalar many times. */ const REPORTED_UNRECOGNIZED_SCALARS = new WeakMap<Program, Set<Scalar>>(); /** * Reports a scalar as unrecognized, so that the spec author knows it is treated as `unknown`. * * @param ctx - The emitter context. * @param scalar - The scalar that was not recognized. * @param target - The diagnostic target to report the error on. */ 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); } /** * Gets the default string encoder for HTTP metadata. */ function getDefaultHttpStringEncoder( ctx: JsContext, module: Module, form: HttpOperationParameter["type"], ): Encoder { const string = ctx.program.checker.getStdType("string"); const scalar = getJsScalar(ctx, module, string, NoTarget); return { target: scalar, encode: HTTP_ENCODE_STRING, decode: HTTP_DECODE_STRING, }; } // Encoders for HTTP metadata. const HTTP_ENCODE_STRING: Encoder["encode"] = (subject) => `JSON.stringify(${subject})`; const HTTP_DECODE_STRING: Encoder["decode"] = (subject) => `JSON.parse(${subject})`; /** * An encoder that encodes a scalar type to the `target` scalar type. * * The type that this encoder encodes _from_ is the type of the scalar that it is bound to. It _MUST_ be used only with expressions * of the type that represents the source scalar. */ export interface Encoder { /** * The target scalar type that this encoder encodes to. */ readonly target: JsScalar; /** * Produces an expression that encodes the `subject` expression of the source type into the target. * * @param subject - An expression of the type that represents the source scalar. */ encode(subject: string): string; /** * Produces an expression that decodes the `subject` expression from the target into the source type. * * @param subject - An expression of the type that represents the target scalar. */ decode(subject: string): string; } /** * A representation of a TypeSpec scalar in TypeScript. */ export interface JsScalar { /** * The TypeScript type that represents the scalar. */ readonly type: string; /** * The TypeSpec scalar that it represents, or "unknown" if the Scalar is not recognized. */ readonly scalar: Scalar | "unknown"; /** * Get an encoder that encodes this scalar type to a different scalar type using a given encoding. * * @param encoding - the encoding to use (e.g. "base64", "base64url", etc.) * @param target - the target scalar type to encode to * @returns an encoder that encodes this scalar type to the target scalar type using the given encoding, or undefined * if the encoding is not supported. */ getEncoding(encoding: string, target: Scalar): Encoder | undefined; getEncoding(encoding: EncodeData): Encoder | undefined; /** * Get the default encoder for a given media type. * * @param mimeType - the media type to get the default encoder for (e.g. "application/json", "text/plain", etc.) * @returns an encoder that encodes this scalar type to the target scalar type using the given encoding, or undefined * if no default encoder is defined for the given media type. */ getDefaultMimeEncoding(mimeType: string): Encoder | undefined; /** * Whether this scalar can be used directly in JSON serialization. * * If true, this scalar will be represented faithfully if it is passed to JSON.stringify or JSON.parse. */ isJsonCompatible: boolean; /** * A map of encoders when this type is used in HTTP metadata. */ readonly http: { readonly [K in HttpOperationParameter["type"]]: Encoder; }; } /** * A dummy encoder that just converts the value to a string and does not decode it. * * This is used for "unknown" scalars. */ const DEFAULT_STRING_ENCODER_RAW: Omit<Encoder, "target"> = { encode(subject) { return `String(${subject})`; }, decode(subject) { return `${subject}`; }, }; /** * A JsScalar value that represents an unknown scalar. */ export const JS_SCALAR_UNKNOWN: JsScalar = { type: "unknown", scalar: "unknown", getEncoding: () => undefined, getDefaultMimeEncoding: () => undefined, http: { get header() { return { target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, get query() { return { target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, get cookie() { return { target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, get path() { return { target: JS_SCALAR_UNKNOWN, ...DEFAULT_STRING_ENCODER_RAW, }; }, }, isJsonCompatible: true, }; /** * Gets a TypeScript type that can represent a given TypeSpec scalar. * * Scalar recognition is recursive. If a scalar is not recognized, we will treat it as its parent scalar and try again. * * If no scalar in the chain is recognized, it will be treated as `unknown` and a warning will be issued. * * @param program - The program that contains the scalar * @param scalar - The scalar to get the TypeScript type for * @param diagnosticTarget - Where to report a diagnostic if the scalar is not recognized. * @returns a string containing a TypeScript type that can represent the scalar */ export function getJsScalar( ctx: JsContext, module: Module, scalar: Scalar, diagnosticTarget: DiagnosticTarget | typeof NoTarget, ): JsScalar { const scalars = getScalarStore(ctx); let _scalar: Scalar | undefined = scalar; while (_scalar !== undefined) { const jsScalar = scalars.get(_scalar); if (jsScalar !== undefined) { return jsScalar(ctx, module); } _scalar = _scalar.baseScalar; } reportUnrecognizedScalar(ctx, scalar, diagnosticTarget); return JS_SCALAR_UNKNOWN; }