diff --git a/.chronus/changes/fix-unknown-scalar-constructors-2026-0-27-17-36-5-2.md b/.chronus/changes/fix-unknown-scalar-constructors-2026-0-27-17-36-5-2.md new file mode 100644 index 00000000000..7df3599b6d4 --- /dev/null +++ b/.chronus/changes/fix-unknown-scalar-constructors-2026-0-27-17-36-5-2.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@typespec/openapi3" +--- + +Handle use of `.now()` constructor on date time types in examples and default. diff --git a/.chronus/changes/fix-unknown-scalar-constructors-2026-0-27-17-36-5.md b/.chronus/changes/fix-unknown-scalar-constructors-2026-0-27-17-36-5.md new file mode 100644 index 00000000000..04e171903e8 --- /dev/null +++ b/.chronus/changes/fix-unknown-scalar-constructors-2026-0-27-17-36-5.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +[API] `serializeValueAsJson` throws a `UnsupportedScalarConstructorError` for unsupported scalar constructor instead of crashing diff --git a/packages/compiler/src/lib/examples.ts b/packages/compiler/src/lib/examples.ts index 87e77dedd9d..2aa64ba0d7b 100644 --- a/packages/compiler/src/lib/examples.ts +++ b/packages/compiler/src/lib/examples.ts @@ -12,6 +12,38 @@ import { } from "../core/types.js"; import { getEncode, resolveEncodedName, type EncodeData } from "./decorators.js"; +/** + * Error thrown when a scalar value cannot be serialized because it uses an unsupported constructor. + */ +export class UnsupportedScalarConstructorError extends Error { + constructor( + public readonly scalarName: string, + public readonly constructorName: string, + public readonly supportedConstructors: readonly string[], + ) { + super( + `Cannot serialize scalar '${scalarName}' with constructor '${constructorName}'. Supported constructors: ${supportedConstructors.join(", ")}`, + ); + this.name = "UnsupportedScalarConstructorError"; + } +} + +export interface ValueJsonSerializers { + /** Custom handler to serialize a scalar value + * @param value The scalar value to serialize + * @param type The type of the scalar value in the current context + * @param encodeAs The encoding information for the scalar value, if any + * @param originalFn The original serialization function to fall back to. Throws `UnsupportedScalarConstructorError` if the scalar constructor is not supported. + * @returns The serialized value + */ + serializeScalarValue?: ( + value: ScalarValue, + type: Type, + encodeAs: EncodeData | undefined, + originalFn: (value: ScalarValue, type: Type, encodeAs: EncodeData | undefined) => unknown, + ) => unknown; +} + /** * Serialize the given TypeSpec value as a JSON object using the given type and its encoding annotations. * The Value MUST be assignable to the given type. @@ -21,9 +53,16 @@ export function serializeValueAsJson( value: Value, type: Type, encodeAs?: EncodeData, + handlers?: ValueJsonSerializers, ): unknown { if (type.kind === "ModelProperty") { - return serializeValueAsJson(program, value, type.type, encodeAs ?? getEncode(program, type)); + return serializeValueAsJson( + program, + value, + type.type, + encodeAs ?? getEncode(program, type), + handlers, + ); } switch (value.valueKind) { case "NullValue": @@ -48,7 +87,7 @@ export function serializeValueAsJson( case "ObjectValue": return serializeObjectValueAsJson(program, value, type); case "ScalarValue": - return serializeScalarValueAsJson(program, value, type, encodeAs); + return serializeScalarValueAsJson(program, value, type, encodeAs, handlers); } } @@ -149,7 +188,17 @@ function serializeScalarValueAsJson( value: ScalarValue, type: Type, encodeAs: EncodeData | undefined, + handlers?: ValueJsonSerializers, ): unknown { + if (handlers?.serializeScalarValue) { + return handlers.serializeScalarValue( + value, + type, + encodeAs, + serializeScalarValueAsJson.bind(null, program, value, type, encodeAs, undefined), + ); + } + const result = resolveKnownScalar(program, value.scalar); if (result === undefined) { return serializeValueAsJson(program, value.value.args[0], value.value.args[0].type); @@ -159,15 +208,30 @@ function serializeScalarValueAsJson( switch (result.scalar.name) { case "utcDateTime": - return ScalarSerializers.utcDateTime((value.value.args[0] as any as any).value, encodeAs); + if (value.value.name === "fromISO") { + return ScalarSerializers.utcDateTime((value.value.args[0] as any).value, encodeAs); + } + throw new UnsupportedScalarConstructorError("utcDateTime", value.value.name, ["fromISO"]); case "offsetDateTime": - return ScalarSerializers.offsetDateTime((value.value.args[0] as any).value, encodeAs); + if (value.value.name === "fromISO") { + return ScalarSerializers.offsetDateTime((value.value.args[0] as any).value, encodeAs); + } + throw new UnsupportedScalarConstructorError("offsetDateTime", value.value.name, ["fromISO"]); case "plainDate": - return ScalarSerializers.plainDate((value.value.args[0] as any).value); + if (value.value.name === "fromISO") { + return ScalarSerializers.plainDate((value.value.args[0] as any).value); + } + throw new UnsupportedScalarConstructorError("plainDate", value.value.name, ["fromISO"]); case "plainTime": - return ScalarSerializers.plainTime((value.value.args[0] as any).value); + if (value.value.name === "fromISO") { + return ScalarSerializers.plainTime((value.value.args[0] as any).value); + } + throw new UnsupportedScalarConstructorError("plainTime", value.value.name, ["fromISO"]); case "duration": - return ScalarSerializers.duration((value.value.args[0] as any).value, encodeAs); + if (value.value.name === "fromISO") { + return ScalarSerializers.duration((value.value.args[0] as any).value, encodeAs); + } + throw new UnsupportedScalarConstructorError("duration", value.value.name, ["fromISO"]); } } diff --git a/packages/openapi3/src/examples.ts b/packages/openapi3/src/examples.ts index fdc7ced5ab8..454bd949735 100644 --- a/packages/openapi3/src/examples.ts +++ b/packages/openapi3/src/examples.ts @@ -691,3 +691,23 @@ function getValueByPath(value: Value, path: (string | number)[]): Value | undefi } return current; } + +export function serializeExample(program: Program, value: Value, type: Type): unknown | undefined { + return serializeValueAsJson(program, value, type, undefined, { + serializeScalarValue: (value, type, encodeAs, originalFn) => { + const scalar = value.scalar; + if (scalar.name === "utcDateTime" && value.value.name === "now") { + return new Date().toUTCString(); + } else if (scalar.name === "offsetDateTime" && value.value.name === "now") { + return new Date().toUTCString(); + } else if (scalar.name === "plainDate" && value.value.name === "now") { + const now = new Date(); + return now.toISOString().split("T")[0]; + } else if (scalar.name === "plainTime" && value.value.name === "now") { + const now = new Date(); + return now.toISOString().split("T")[1].replace("Z", ""); + } + return originalFn(value, type, encodeAs); + }, + }); +} diff --git a/packages/openapi3/src/lib.ts b/packages/openapi3/src/lib.ts index 92ff49def67..234d1bee514 100644 --- a/packages/openapi3/src/lib.ts +++ b/packages/openapi3/src/lib.ts @@ -406,6 +406,12 @@ export const $lib = createTypeSpecLibrary({ "Streams with itemSchema are only fully supported in OpenAPI 3.2.0 or above. The response will be emitted without itemSchema. Consider using OpenAPI 3.2.0 for full stream support.", }, }, + "default-not-supported": { + severity: "warning", + messages: { + default: paramMessage`Default value is not supported in OpenAPI 3.0 ${"message"}`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/openapi3/src/schema-emitter-3-0.ts b/packages/openapi3/src/schema-emitter-3-0.ts index 835b8a40f0d..da93ac944f6 100644 --- a/packages/openapi3/src/schema-emitter-3-0.ts +++ b/packages/openapi3/src/schema-emitter-3-0.ts @@ -19,7 +19,6 @@ import { Model, ModelProperty, Scalar, - serializeValueAsJson, Type, Union, } from "@typespec/compiler"; @@ -27,6 +26,7 @@ import { $ } from "@typespec/compiler/typekit"; import { MetadataInfo } from "@typespec/http"; import { shouldInline } from "@typespec/openapi"; import { getOneOf } from "./decorators.js"; +import { serializeExample } from "./examples.js"; import { JsonSchemaModule } from "./json-schema.js"; import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; import { applyEncoding, getRawBinarySchema } from "./openapi-helpers-3-0.js"; @@ -75,7 +75,7 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase 0) { - setProperty(target, "example", serializeValueAsJson(program, examples[0].value, type)); + setProperty(target, "example", serializeExample(program, examples[0].value, type)); } } diff --git a/packages/openapi3/src/schema-emitter-3-1.ts b/packages/openapi3/src/schema-emitter-3-1.ts index 4211044ec60..42b07d00c28 100644 --- a/packages/openapi3/src/schema-emitter-3-1.ts +++ b/packages/openapi3/src/schema-emitter-3-1.ts @@ -21,7 +21,6 @@ import { ModelProperty, Program, Scalar, - serializeValueAsJson, Tuple, Type, Union, @@ -29,6 +28,7 @@ import { import { MetadataInfo } from "@typespec/http"; import { shouldInline } from "@typespec/openapi"; import { getOneOf } from "./decorators.js"; +import { serializeExample } from "./examples.js"; import { JsonSchemaModule } from "./json-schema.js"; import { OpenAPI3EmitterOptions, reportDiagnostic } from "./lib.js"; import { applyEncoding, getRawBinarySchema } from "./openapi-helpers-3-1.js"; @@ -80,7 +80,7 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase serializeValueAsJson(program, example.value, type)), + examples.map((example) => serializeExample(program, example.value, type)), ); } } diff --git a/packages/openapi3/src/util.ts b/packages/openapi3/src/util.ts index 36c4b52ee59..67cbffefe25 100644 --- a/packages/openapi3/src/util.ts +++ b/packages/openapi3/src/util.ts @@ -14,7 +14,7 @@ import { Value, } from "@typespec/compiler"; import { HttpOperation, HttpProperty } from "@typespec/http"; -import { createDiagnostic } from "./lib.js"; +import { createDiagnostic, reportDiagnostic } from "./lib.js"; /** * Checks if two objects are deeply equal. * @@ -153,7 +153,21 @@ export function getDefaultValue( defaultType: Value, modelProperty: ModelProperty, ): any { - return serializeValueAsJson(program, defaultType, modelProperty); + try { + return serializeValueAsJson(program, defaultType, modelProperty); + } catch (e) { + if (e instanceof Error && e.name === "UnsupportedScalarConstructorError") { + reportDiagnostic(program, { + code: "default-not-supported", + format: { + message: e.message, + }, + target: modelProperty, + }); + return undefined; + } + throw e; + } } export function isBytesKeptRaw(program: Program, type: Type) { diff --git a/packages/openapi3/test/models.test.ts b/packages/openapi3/test/models.test.ts index 1f25eeccea4..7ce314dec71 100644 --- a/packages/openapi3/test/models.test.ts +++ b/packages/openapi3/test/models.test.ts @@ -2,6 +2,7 @@ import { DiagnosticTarget } from "@typespec/compiler"; import { expectDiagnostics } from "@typespec/compiler/testing"; import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, expect, it } from "vitest"; +import { emitOpenApiWithDiagnostics } from "./test-host.js"; import { supportedVersions, worksFor } from "./works-for.js"; worksFor(supportedVersions, ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) => { @@ -285,6 +286,19 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, oapiForModel, openApiFor }) = expect(res.schemas.Test.properties.minDate.default).toEqual("Mon, 01 Jan 2024 11:32:00 GMT"); }); + it("throw warning for scalar constructor that don't have equivalent", async () => { + const [res, diagnostics] = await emitOpenApiWithDiagnostics( + `model Test { minDate: utcDateTime = utcDateTime.now(); }`, + ); + + expect((res as any).components?.schemas?.Test?.properties?.minDate.default).toEqual(undefined); + expectDiagnostics(diagnostics, { + code: "@typespec/openapi3/default-not-supported", + message: + "Default value is not supported in OpenAPI 3.0 Cannot serialize scalar 'utcDateTime' with constructor 'now'. Supported constructors: fromISO", + }); + }); + it("object value used as a default value", async () => { const res = await oapiForModel( "Test",