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,9 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: fix
packages:
- "@typespec/json-schema"
- "@typespec/openapi"
---

Fix crash when using enum values in extension
46 changes: 46 additions & 0 deletions packages/json-schema/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
type Namespace,
type Program,
type Scalar,
serializeValueAsJson,
setTypeSpecNamespace,
type Tuple,
type Type,
typespecTypeToJson,
type Union,
type Value,
} from "@typespec/compiler";
import { useStateMap, useStateSet } from "@typespec/compiler/utils";
import type { ValidatesRawJsonDecorator } from "../generated-defs/TypeSpec.JsonSchema.Private.js";
Expand Down Expand Up @@ -257,9 +259,53 @@ export const $extension: ExtensionDecorator = (
key: string,
value: unknown,
) => {
if (!isTypeLike(value)) {
value = convertRemainingValuesToExtensions(context.program, value);
}
setExtension(context.program, target, key, value);
};

// Workaround until we have a way to disable arg marshalling and just call serializeValueAsJson
// https://github.com/microsoft/typespec/issues/3570
function convertRemainingValuesToExtensions(program: Program, value: unknown): unknown {
switch (typeof value) {
case "string":
case "number":
case "boolean":
return value;
case "object":
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return value.map((x) => convertRemainingValuesToExtensions(program, x));
}

if (isTypeSpecValue(value)) {
return serializeValueAsJson(program, value, value.type);
} else {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (val === undefined) {
continue;
}
result[key] = convertRemainingValuesToExtensions(program, val);
}
return result;
}
default:
return value;
}
}

function isTypeLike(value: any): value is Type {
return typeof value === "object" && value !== null && isType(value);
}

function isTypeSpecValue(value: object): value is Value {
return "entityKind" in value && value.entityKind === "Value";
}

/**
* Get extensions set via the `@extension` decorator on the given type
* @param program TypeSpec program
Expand Down
46 changes: 21 additions & 25 deletions packages/json-schema/test/extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DecoratorContext, Type } from "@typespec/compiler";
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
import assert from "assert";
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import { setExtension } from "../src/index.js";
import { emitSchema, emitSchemaWithDiagnostics } from "./utils.js";

Expand Down Expand Up @@ -150,30 +150,26 @@ it("handles Json-wrapped types", async () => {
assert.deepStrictEqual(Foo.properties.x["x-string-literal"], "hi");
});

// These tests are skipped - can enable if @extension is updated to support `valueof unknown`
it("handles values", async () => {
const schemas = await emitSchema(`
@extension("x-anon-model", #{ name: "foo" })
@extension("x-nested-anon-model", #{ items: #[ #{foo: "bar" }]})
@extension("x-tuple", #["foo"])
model Foo {
@extension("x-bool-literal", true)
@extension("x-int-literal", 42)
@extension("x-string-literal", "hi")
@extension("x-null", null)
x: string;
}
`);
const Foo = schemas["Foo.json"];

assert.deepStrictEqual(Foo["x-anon-model"], { name: "foo" });
assert.deepStrictEqual(Foo["x-nested-anon-model"], { items: [{ foo: "bar" }] });
assert.deepStrictEqual(Foo["x-tuple"], ["foo"]);

assert.deepStrictEqual(Foo.properties.x["x-bool-literal"], true);
assert.deepStrictEqual(Foo.properties.x["x-int-literal"], 42);
assert.deepStrictEqual(Foo.properties.x["x-string-literal"], "hi");
assert.deepStrictEqual(Foo.properties.x["x-null"], null);
describe("values", () => {
it.each([
["string", `"foo"`, "foo"],
["number", `42`, 42],
["boolean", `true`, true],
["null", `null`, null],
["array", `#["foo", 42, true]`, ["foo", 42, true]],
["object", `#{foo: "bar"}`, { foo: "bar" }],
["enum value", "Direction.Up", "Up", "enum Direction {Up, Down}"],
["enum value in object", "#{ dir: Direction.Up }", { dir: "Up" }, "enum Direction {Up, Down}"],
])("%s", async (_, value, expected, extra?: string) => {
const schemas = await emitSchema(
`
${extra ?? ""}
@extension("x-custom", ${value})
model Foo {}
`,
);
expect(schemas["Foo.json"]["x-custom"]).toEqual(expected);
});
});

describe("setExtension", () => {
Expand Down
42 changes: 41 additions & 1 deletion packages/openapi/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
Namespace,
Operation,
Program,
serializeValueAsJson,
Type,
Value,
} from "@typespec/compiler";
import { useStateMap } from "@typespec/compiler/utils";
import * as http from "@typespec/http";
Expand Down Expand Up @@ -63,9 +65,47 @@ export const $extension: ExtensionDecorator = (
"OpenAPI extension value must be a value but was a type",
context.getArgumentTarget(1),
);
setExtension(context.program, entity, extensionName as ExtensionKey, value);
const processed = convertRemainingValuesToExtensions(context.program, value);
setExtension(context.program, entity, extensionName as ExtensionKey, processed);
};

// Workaround until we have a way to disable arg marshalling and just call serializeValueAsJson
// https://github.com/microsoft/typespec/issues/3570
function convertRemainingValuesToExtensions(program: Program, value: unknown): unknown {
switch (typeof value) {
case "string":
case "number":
case "boolean":
return value;
case "object":
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return value.map((x) => convertRemainingValuesToExtensions(program, x));
}

if (isTypeSpecValue(value)) {
return serializeValueAsJson(program, value, value.type);
} else {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (val === undefined) {
continue;
}
result[key] = convertRemainingValuesToExtensions(program, val);
}
return result;
}
default:
return value;
}
}

function isTypeSpecValue(value: object): value is Value {
return "entityKind" in value && value.entityKind === "Value";
}

/**
* Set the OpenAPI info node on for the given service namespace.
* @param program Program
Expand Down
81 changes: 81 additions & 0 deletions packages/openapi3/test/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { ok, strictEqual } from "assert";
import { describe, expect, it } from "vitest";
import { openApiFor } from "./test-host.js";

describe("target", () => {
it("add to model", async () => {
const oapi = await openApiFor(`
@extension("x-model-extension", "foobar")
model Pet {}
`);
ok(oapi.components.schemas.Pet);
strictEqual(oapi.components.schemas.Pet["x-model-extension"], "foobar");
});

it("operation", async () => {
const oapi = await openApiFor(
`
@extension("x-operation-extension", "barbaz")
op list(): string[];
`,
);
ok(oapi.paths["/"].get);
strictEqual(oapi.paths["/"].get["x-operation-extension"], "barbaz");
});

it("parameter component", async () => {
const oapi = await openApiFor(
`
model Pet {
name: string;
}
model PetId {
@path
@extension("x-parameter-extension", "foobaz")
petId: string;
}
@route("/Pets") @get op get(... PetId): Pet;
`,
);
ok(oapi.paths["/Pets/{petId}"].get);
strictEqual(
oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"],
"#/components/parameters/PetId",
);
strictEqual(oapi.components.parameters.PetId.name, "petId");
strictEqual(oapi.components.parameters.PetId["x-parameter-extension"], "foobaz");
});

it("adds at root of document when on namespace", async () => {
const oapi = await openApiFor(
`
@extension("x-namespace-extension", "foobar")
@service namespace Service {};
`,
);

strictEqual(oapi["x-namespace-extension"], "foobar");
});
});

describe("extension value", () => {
it.each([
["string", `"foo"`, "foo"],
["number", `42`, 42],
["boolean", `true`, true],
["null", `null`, null],
["array", `#["foo", 42, true]`, ["foo", 42, true]],
["object", `#{foo: "bar"}`, { foo: "bar" }],
["enum value", "Direction.Up", "Up", "enum Direction {Up, Down}"],
["enum value in object", "#{ dir: Direction.Up }", { dir: "Up" }, "enum Direction {Up, Down}"],
])("%s", async (_, value, expected, extra?: string) => {
const oapi = await openApiFor(
`
${extra ?? ""}
@extension("x-custom", ${value})
model Foo {}
`,
);
expect(oapi.components.schemas.Foo["x-custom"]).toEqual(expected);
});
});
91 changes: 13 additions & 78 deletions packages/openapi3/test/openapi-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,73 +193,9 @@ worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor, openapiWithOptions })
strictEqual(res.components.parameters["PetId.name"].deprecated, undefined);
});

describe("openapi3: extension decorator", () => {
it("adds an arbitrary extension to a model", async () => {
const oapi = await openApiFor(`
@extension("x-model-extension", "foobar")
model Pet {
name: string;
}
@get() op read(): Pet;
`);
ok(oapi.components.schemas.Pet);
strictEqual(oapi.components.schemas.Pet["x-model-extension"], "foobar");
});

it("adds an arbitrary extension to an operation", async () => {
const oapi = await openApiFor(
`
model Pet {
name: string;
}
@get()
@extension("x-operation-extension", "barbaz")
op list(): Pet[];
`,
);
ok(oapi.paths["/"].get);
strictEqual(oapi.paths["/"].get["x-operation-extension"], "barbaz");
});

it("adds an arbitrary extension to a parameter", async () => {
const oapi = await openApiFor(
`
model Pet {
name: string;
}
model PetId {
@path
@extension("x-parameter-extension", "foobaz")
petId: string;
}
@route("/Pets")
@get()
op get(... PetId): Pet;
`,
);
ok(oapi.paths["/Pets/{petId}"].get);
strictEqual(
oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"],
"#/components/parameters/PetId",
);
strictEqual(oapi.components.parameters.PetId.name, "petId");
strictEqual(oapi.components.parameters.PetId["x-parameter-extension"], "foobaz");
});

it("adds an extension to a namespace", async () => {
const oapi = await openApiFor(
`
@extension("x-namespace-extension", "foobar")
@service namespace Service {};
`,
);

strictEqual(oapi["x-namespace-extension"], "foobar");
});

it("check format and pattern decorator on model", async () => {
const oapi = await openApiFor(
`
it("check format and pattern decorator on model", async () => {
const oapi = await openApiFor(
`
model Pet extends PetId {
@pattern("^[a-zA-Z0-9-]{3,24}$")
name: string;
Expand All @@ -274,17 +210,16 @@ worksFor(["3.0.0", "3.1.0"], ({ oapiForModel, openApiFor, openapiWithOptions })
@get()
op get(... PetId): Pet;
`,
);
ok(oapi.paths["/Pets/{petId}"].get);
strictEqual(
oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"],
"#/components/parameters/PetId",
);
strictEqual(oapi.components.parameters.PetId.name, "petId");
strictEqual(oapi.components.schemas.Pet.properties.name.pattern, "^[a-zA-Z0-9-]{3,24}$");
strictEqual(oapi.components.parameters.PetId.schema.format, "UUID");
strictEqual(oapi.components.parameters.PetId.schema.pattern, "^[a-zA-Z0-9-]{3,24}$");
});
);
ok(oapi.paths["/Pets/{petId}"].get);
strictEqual(
oapi.paths["/Pets/{petId}"].get.parameters[0]["$ref"],
"#/components/parameters/PetId",
);
strictEqual(oapi.components.parameters.PetId.name, "petId");
strictEqual(oapi.components.schemas.Pet.properties.name.pattern, "^[a-zA-Z0-9-]{3,24}$");
strictEqual(oapi.components.parameters.PetId.schema.format, "UUID");
strictEqual(oapi.components.parameters.PetId.schema.pattern, "^[a-zA-Z0-9-]{3,24}$");
});

describe("openapi3: useRef decorator", () => {
Expand Down
Loading