diff --git a/packages/rest/README.md b/packages/rest/README.md index 6b7744b7804..b1fa1ec0419 100644 --- a/packages/rest/README.md +++ b/packages/rest/README.md @@ -57,7 +57,7 @@ Defines the separator string that is inserted before the action name in auto-gen ##### Target -`Model | ModelProperty | Operation` +`Operation | Interface | Namespace` ##### Parameters diff --git a/packages/rest/generated-defs/TypeSpec.Rest.ts b/packages/rest/generated-defs/TypeSpec.Rest.ts index 90f68ad386e..aa1d60c37c4 100644 --- a/packages/rest/generated-defs/TypeSpec.Rest.ts +++ b/packages/rest/generated-defs/TypeSpec.Rest.ts @@ -3,6 +3,7 @@ import type { Interface, Model, ModelProperty, + Namespace, Operation, } from "@typespec/compiler"; @@ -58,7 +59,7 @@ export type SegmentOfDecorator = ( */ export type ActionSeparatorDecorator = ( context: DecoratorContext, - target: Model | ModelProperty | Operation, + target: Operation | Interface | Namespace, seperator: "/" | ":" | "/:", ) => void; diff --git a/packages/rest/lib/rest-decorators.tsp b/packages/rest/lib/rest-decorators.tsp index 6f1d96ea5f9..325ac383df0 100644 --- a/packages/rest/lib/rest-decorators.tsp +++ b/packages/rest/lib/rest-decorators.tsp @@ -41,7 +41,7 @@ extern dec segmentOf(target: Operation, type: Model); * @param seperator Seperator seperating the action segment from the rest of the url */ extern dec actionSeparator( - target: Model | ModelProperty | Operation, + target: Operation | Interface | Namespace, seperator: valueof "/" | ":" | "/:" ); diff --git a/packages/rest/src/rest.ts b/packages/rest/src/rest.ts index 82cae4b25f8..c92c9001536 100644 --- a/packages/rest/src/rest.ts +++ b/packages/rest/src/rest.ts @@ -5,6 +5,7 @@ import { Interface, Model, ModelProperty, + Namespace, Operation, Program, Scalar, @@ -273,11 +274,11 @@ const actionSeparatorKey = createStateSymbol("actionSeparator"); * `@actionSeparator` defines the separator string that is used to precede the action name * in auto-generated actions. * - * `@actionSeparator` can only be applied to model properties, operation parameters, or operations. + * `@actionSeparator` can only be applied to operations, interfaces, or namespaces. */ export function $actionSeparator( context: DecoratorContext, - entity: Model | ModelProperty | Operation, + entity: Operation | Interface | Namespace, separator: "/" | ":" | "/:", ) { context.program.stateMap(actionSeparatorKey).set(entity, separator); diff --git a/packages/rest/test/action-separator.test.ts b/packages/rest/test/action-separator.test.ts new file mode 100644 index 00000000000..90fe5d71e90 --- /dev/null +++ b/packages/rest/test/action-separator.test.ts @@ -0,0 +1,121 @@ +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { Tester, getRoutesFor } from "./test-host.js"; + +describe("rest: @actionSeparator decorator", () => { + describe("valid targets", () => { + it("works on Operation and affects routing", async () => { + const routes = await getRoutesFor(` + @autoRoute + interface Things { + @action + @TypeSpec.Rest.actionSeparator(":") + @put op customAction(@segment("things") @path thingId: string): string; + } + `); + + strictEqual(routes.length, 1); + strictEqual(routes[0].path, "/things/{thingId}:customAction"); + }); + + it("accepts Interface as target without compilation errors", async () => { + // This test verifies that @actionSeparator can be applied to interfaces without errors + const diagnostics = await Tester.diagnose(` + @TypeSpec.Rest.actionSeparator(":") + interface TestInterface { + op test(): void; + } + `); + + // No diagnostics means the decorator accepts interfaces as valid targets + strictEqual(diagnostics.length, 0); + }); + + it("accepts Namespace as target without compilation errors", async () => { + // This test verifies that @actionSeparator can be applied to namespaces without errors + const diagnostics = await Tester.diagnose(` + @TypeSpec.Rest.actionSeparator(":") + namespace TestNamespace { + op test(): void; + } + `); + + // No diagnostics means the decorator accepts namespaces as valid targets + strictEqual(diagnostics.length, 0); + }); + + it("supports all separator values in routing", async () => { + const routes = await getRoutesFor(` + @autoRoute + interface Things { + @action + @TypeSpec.Rest.actionSeparator("/") + @put op action1(@segment("things") @path thingId: string): string; + + @action + @TypeSpec.Rest.actionSeparator(":") + @put op action2(@segment("things") @path thingId: string): string; + + @action + @TypeSpec.Rest.actionSeparator("/:") + @put op action3(@segment("things") @path thingId: string): string; + } + `); + + strictEqual(routes.length, 3); + strictEqual(routes[0].path, "/things/{thingId}/action1"); + strictEqual(routes[1].path, "/things/{thingId}:action2"); + strictEqual(routes[2].path, "/things/{thingId}/:action3"); + }); + }); + + describe("invalid targets", () => { + it("rejects Model", async () => { + const diagnostics = await Tester.diagnose(` + @TypeSpec.Rest.actionSeparator(":") + model TestModel { + id: string; + } + `); + + expectDiagnostics(diagnostics, [ + { + code: "decorator-wrong-target", + message: + "Cannot apply @actionSeparator decorator to TestModel since it is not assignable to Operation | Interface | Namespace", + }, + ]); + }); + + it("rejects ModelProperty", async () => { + const diagnostics = await Tester.diagnose(` + model TestModel { + @TypeSpec.Rest.actionSeparator(":") + id: string; + } + `); + + expectDiagnostics(diagnostics, [ + { + code: "decorator-wrong-target", + message: + /Cannot apply @actionSeparator decorator to .* since it is not assignable to Operation \| Interface \| Namespace/, + }, + ]); + }); + + it("rejects invalid separator values", async () => { + const diagnostics = await Tester.diagnose(` + @TypeSpec.Rest.actionSeparator("invalid") + op test(): void; + `); + + expectDiagnostics(diagnostics, [ + { + code: "invalid-argument", + }, + ]); + }); + }); +}); \ No newline at end of file