-
Notifications
You must be signed in to change notification settings - Fork 336
Fix @actionSeparator decorator to accept Operation, Interface, and Namespace targets with hierarchy support #8609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
markcowl
merged 9 commits into
main
from
copilot/fix-62e88d88-0048-4ae7-8e70-1730bf31543c
Feb 6, 2026
+315
−11
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
d0b0916
Initial plan
Copilot c0ac5d3
Update @actionSeparator decorator to accept only Operation, Interface…
Copilot dd9f71a
Complete @actionSeparator fix with tests and documentation
Copilot 7cbbe36
Merge branch 'main' into copilot/fix-62e88d88-0048-4ae7-8e70-1730bf31…
markcowl 2abb35a
Add changelog entry for @actionSeparator decorator fix
Copilot 3c2e1c1
Implement hierarchy behavior for @actionSeparator decorator
Copilot be5887b
Fix formatting and include generated TypeSpec definitions
Copilot 685317a
Merge branch 'main' into copilot/fix-62e88d88-0048-4ae7-8e70-1730bf31…
markcowl 78184b4
Address code review feedback
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
...s/changes/copilot-fix-62e88d88-0048-4ae7-8e70-1730bf31543c-2026-1-2-23-34-15.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| changeKind: fix | ||
| packages: | ||
| - "@typespec/rest" | ||
| --- | ||
|
|
||
| Fix `@actionSeparator` decorator to only accept Operation, Interface, and Namespace targets |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| import { expectDiagnostics } from "@typespec/compiler/testing"; | ||
| import { strictEqual } from "assert"; | ||
| import { describe, it } from "vitest"; | ||
| import { Tester, getRoutesFor } from "./test-host.js"; | ||
|
|
||
| describe("valid targets", () => { | ||
| it("works on Operation and affects routing", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @autoRoute | ||
| interface Things { | ||
| @action | ||
| @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(` | ||
| @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(` | ||
| @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 | ||
| @actionSeparator("/") | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
|
|
||
| @action | ||
| @actionSeparator(":") | ||
| @put op action2(@segment("things") @path thingId: string): string; | ||
|
|
||
| @action | ||
| @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("hierarchy behavior", () => { | ||
| it("interface-level separator applies to all actions in interface", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @autoRoute | ||
| @actionSeparator(":") | ||
| interface Things { | ||
| @action | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
|
|
||
| @action | ||
| @put op action2(@segment("things") @path thingId: string): string; | ||
| } | ||
| `); | ||
|
|
||
| strictEqual(routes.length, 2); | ||
| strictEqual(routes[0].path, "/things/{thingId}:action1"); | ||
| strictEqual(routes[1].path, "/things/{thingId}:action2"); | ||
| }); | ||
|
|
||
| it("namespace-level separator applies to all actions in namespace", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @actionSeparator(":") | ||
| namespace TestNs { | ||
| @autoRoute | ||
| interface Things { | ||
| @action | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
| } | ||
| } | ||
| `); | ||
|
|
||
| strictEqual(routes.length, 1); | ||
| strictEqual(routes[0].path, "/things/{thingId}:action1"); | ||
| }); | ||
|
|
||
| it("operation-level separator overrides interface-level separator", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @autoRoute | ||
| @actionSeparator(":") | ||
| interface Things { | ||
| @action | ||
| @actionSeparator("/") | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
|
|
||
| @action | ||
| @put op action2(@segment("things") @path thingId: string): string; | ||
| } | ||
| `); | ||
|
|
||
| strictEqual(routes.length, 2); | ||
| strictEqual(routes[0].path, "/things/{thingId}/action1"); // Uses operation-level "/" | ||
| strictEqual(routes[1].path, "/things/{thingId}:action2"); // Uses interface-level ":" | ||
| }); | ||
|
|
||
| it("interface-level separator overrides namespace-level separator", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @actionSeparator("/:") | ||
| namespace TestNs { | ||
| @autoRoute | ||
| @actionSeparator(":") | ||
| interface Things { | ||
| @action | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
| } | ||
|
|
||
| @autoRoute | ||
| interface Other { | ||
| @action | ||
| @put op action2(@segment("other") @path otherId: string): string; | ||
| } | ||
| } | ||
| `); | ||
|
|
||
| strictEqual(routes.length, 2); | ||
| strictEqual(routes[0].path, "/things/{thingId}:action1"); // Uses interface-level ":" | ||
| strictEqual(routes[1].path, "/other/{otherId}/:action2"); // Uses namespace-level "/:" | ||
| }); | ||
|
|
||
| it("namespace separator applies to subnamespaces", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @actionSeparator(":") | ||
| namespace Parent { | ||
| namespace Child { | ||
| @autoRoute | ||
| interface Things { | ||
| @action | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
| } | ||
| } | ||
| } | ||
| `); | ||
|
|
||
| strictEqual(routes.length, 1); | ||
| strictEqual(routes[0].path, "/things/{thingId}:action1"); // Uses parent namespace-level ":" | ||
| }); | ||
|
|
||
| it("operation in namespace without interface uses namespace separator", async () => { | ||
| const routes = await getRoutesFor(` | ||
| @actionSeparator(":") | ||
| namespace TestNs { | ||
| @autoRoute | ||
| @action | ||
| @put op action1(@segment("things") @path thingId: string): string; | ||
| } | ||
| `); | ||
|
|
||
| strictEqual(routes.length, 1); | ||
| strictEqual(routes[0].path, "/things/{thingId}:action1"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("invalid targets", () => { | ||
| it("rejects Model", async () => { | ||
| const diagnostics = await Tester.diagnose(` | ||
| @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 { | ||
| @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(` | ||
| @actionSeparator("invalid") | ||
| op test(): void; | ||
| `); | ||
|
|
||
| expectDiagnostics(diagnostics, { | ||
| code: "invalid-argument", | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.