@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
1,168 lines (1,028 loc) • 34.3 kB
text/typescript
// 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;
}