Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
78 changes: 71 additions & 7 deletions packages/compiler/src/lib/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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":
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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"]);
}
}

Expand Down
20 changes: 20 additions & 0 deletions packages/openapi3/src/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
}
6 changes: 6 additions & 0 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenAPI3EmitterOptions>,
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi3/src/schema-emitter-3-0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ import {
Model,
ModelProperty,
Scalar,
serializeValueAsJson,
Type,
Union,
} from "@typespec/compiler";
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";
Expand Down Expand Up @@ -75,7 +75,7 @@ export class OpenAPI3SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPI3Sch
const program = this.emitter.getProgram();
const examples = getExamples(program, type);
if (examples.length > 0) {
setProperty(target, "example", serializeValueAsJson(program, examples[0].value, type));
setProperty(target, "example", serializeExample(program, examples[0].value, type));
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/openapi3/src/schema-emitter-3-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ import {
ModelProperty,
Program,
Scalar,
serializeValueAsJson,
Tuple,
Type,
Union,
} from "@typespec/compiler";
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";
Expand Down Expand Up @@ -80,7 +80,7 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
setProperty(
target,
"examples",
examples.map((example) => serializeValueAsJson(program, example.value, type)),
examples.map((example) => serializeExample(program, example.value, type)),
);
}
}
Expand Down
18 changes: 16 additions & 2 deletions packages/openapi3/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions packages/openapi3/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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",
Expand Down
Loading