From 0db9a6f30d4b381aa80cb7c7957fe5c94684a5a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:01:17 +0000 Subject: [PATCH 1/3] Initial plan From 1e262da6e962acb577945a374eca4b5c128da65a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:15:09 +0000 Subject: [PATCH 2/3] feat: create new @typespec/rest-api library with standard REST terminology Add new TypeSpec library package for defining RESTful APIs using common REST terminology including: @resource, @parentResource, @reads, @creates, @createsOrReplaces, @updates, @deletes, @lists, @action, @collectionAction decorators and CRUD operation template interfaces. Co-authored-by: johanste <15110018+johanste@users.noreply.github.com> --- .../generated-defs/TypeSpec.RestApi.ts | 155 ++++++ .../TypeSpec.RestApi.ts-test.ts | 10 + packages/rest-api/lib/decorators.tsp | 94 ++++ packages/rest-api/lib/main.tsp | 6 + packages/rest-api/lib/resources.tsp | 158 +++++++ packages/rest-api/package.json | 59 +++ packages/rest-api/src/decorators.ts | 445 ++++++++++++++++++ packages/rest-api/src/index.ts | 29 ++ packages/rest-api/src/lib.ts | 48 ++ packages/rest-api/src/testing/index.ts | 6 + packages/rest-api/src/tsp-index.ts | 29 ++ packages/rest-api/tsconfig.build.json | 8 + packages/rest-api/tsconfig.json | 9 + packages/rest-api/vitest.config.ts | 4 + pnpm-lock.yaml | 247 +++++++++- 15 files changed, 1305 insertions(+), 2 deletions(-) create mode 100644 packages/rest-api/generated-defs/TypeSpec.RestApi.ts create mode 100644 packages/rest-api/generated-defs/TypeSpec.RestApi.ts-test.ts create mode 100644 packages/rest-api/lib/decorators.tsp create mode 100644 packages/rest-api/lib/main.tsp create mode 100644 packages/rest-api/lib/resources.tsp create mode 100644 packages/rest-api/package.json create mode 100644 packages/rest-api/src/decorators.ts create mode 100644 packages/rest-api/src/index.ts create mode 100644 packages/rest-api/src/lib.ts create mode 100644 packages/rest-api/src/testing/index.ts create mode 100644 packages/rest-api/src/tsp-index.ts create mode 100644 packages/rest-api/tsconfig.build.json create mode 100644 packages/rest-api/tsconfig.json create mode 100644 packages/rest-api/vitest.config.ts diff --git a/packages/rest-api/generated-defs/TypeSpec.RestApi.ts b/packages/rest-api/generated-defs/TypeSpec.RestApi.ts new file mode 100644 index 00000000000..dcb17c9456d --- /dev/null +++ b/packages/rest-api/generated-defs/TypeSpec.RestApi.ts @@ -0,0 +1,155 @@ +import type { + DecoratorContext, + DecoratorValidatorCallbacks, + Model, + Operation, +} from "@typespec/compiler"; + +/** + * Mark a model as a REST resource. A resource represents an entity that can be + * identified by a unique key and managed through standard CRUD operations. + * + * @param collectionName The URL path segment used for the resource collection (e.g., "users", "orders"). + */ +export type ResourceDecorator = ( + context: DecoratorContext, + target: Model, + collectionName: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Establish a parent-child relationship between resources, creating nested URL paths. + * For example, marking `Comment` with `@parentResource(Post)` produces paths like + * `/posts/{postId}/comments/{commentId}`. + * + * @param parent The parent resource model type. + */ +export type ParentResourceDecorator = ( + context: DecoratorContext, + target: Model, + parent: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark an operation as reading a single resource instance (HTTP GET on a resource). + * The operation will be mapped to `GET //{id}`. + * + * @param resourceType The resource model type this operation reads. + */ +export type ReadsDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark an operation as creating a new resource in a collection (HTTP POST). + * The operation will be mapped to `POST /`. + * + * @param resourceType The resource model type this operation creates. + */ +export type CreatesDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark an operation as creating or replacing a resource (HTTP PUT). + * The operation will be mapped to `PUT //{id}`. + * + * @param resourceType The resource model type this operation creates or replaces. + */ +export type CreatesOrReplacesDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark an operation as updating a resource (HTTP PATCH). + * The operation will be mapped to `PATCH //{id}`. + * + * @param resourceType The resource model type this operation updates. + */ +export type UpdatesDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark an operation as deleting a resource (HTTP DELETE). + * The operation will be mapped to `DELETE //{id}`. + * + * @param resourceType The resource model type this operation deletes. + */ +export type DeletesDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark an operation as listing resources from a collection (HTTP GET on a collection). + * The operation will be mapped to `GET /`. + * + * @param resourceType The resource model type this operation lists. + */ +export type ListsDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, +) => DecoratorValidatorCallbacks | void; + +/** + * Define a custom action on a resource instance. + * Creates an endpoint like `POST //{id}/`. + * + * @param name The URL path segment for the action. If not provided, the operation name is used. + */ +export type ActionDecorator = ( + context: DecoratorContext, + target: Operation, + name?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Define a custom action on a resource collection. + * Creates an endpoint like `POST //`. + * + * @param resourceType The resource model type this action operates on. + * @param name The URL path segment for the action. If not provided, the operation name is used. + */ +export type CollectionActionDecorator = ( + context: DecoratorContext, + target: Operation, + resourceType: Model, + name?: string, +) => DecoratorValidatorCallbacks | void; + +/** + * Copy the resource key parameters onto a model. + * Used internally by `KeysOf` and `ParentKeysOf` templates. + * + * @param filter If "parent", only copies parent resource keys. + */ +export type CopyResourceKeyParametersDecorator = ( + context: DecoratorContext, + target: Model, + filter?: string, +) => DecoratorValidatorCallbacks | void; + +export type TypeSpecRestApiDecorators = { + resource: ResourceDecorator; + parentResource: ParentResourceDecorator; + reads: ReadsDecorator; + creates: CreatesDecorator; + createsOrReplaces: CreatesOrReplacesDecorator; + updates: UpdatesDecorator; + deletes: DeletesDecorator; + lists: ListsDecorator; + action: ActionDecorator; + collectionAction: CollectionActionDecorator; + copyResourceKeyParameters: CopyResourceKeyParametersDecorator; +}; diff --git a/packages/rest-api/generated-defs/TypeSpec.RestApi.ts-test.ts b/packages/rest-api/generated-defs/TypeSpec.RestApi.ts-test.ts new file mode 100644 index 00000000000..63690955928 --- /dev/null +++ b/packages/rest-api/generated-defs/TypeSpec.RestApi.ts-test.ts @@ -0,0 +1,10 @@ +// An error in the imports would mean that the decorator is not exported or +// doesn't have the right name. + +import { $decorators } from "@typespec/rest-api"; +import type { TypeSpecRestApiDecorators } from "./TypeSpec.RestApi.js"; + +/** + * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... + */ +const _: TypeSpecRestApiDecorators = $decorators["TypeSpec.RestApi"]; diff --git a/packages/rest-api/lib/decorators.tsp b/packages/rest-api/lib/decorators.tsp new file mode 100644 index 00000000000..bf05f2f254c --- /dev/null +++ b/packages/rest-api/lib/decorators.tsp @@ -0,0 +1,94 @@ +using TypeSpec.Http; +using TypeSpec.Reflection; + +namespace TypeSpec.RestApi; + +/** + * Mark a model as a REST resource. A resource represents an entity that can be + * identified by a unique key and managed through standard CRUD operations. + * + * @param collectionName The URL path segment used for the resource collection (e.g., "users", "orders"). + */ +extern dec resource(target: Model, collectionName: valueof string); + +/** + * Establish a parent-child relationship between resources, creating nested URL paths. + * For example, marking `Comment` with `@parentResource(Post)` produces paths like + * `/posts/{postId}/comments/{commentId}`. + * + * @param parent The parent resource model type. + */ +extern dec parentResource(target: Model, parent: Model); + +/** + * Mark an operation as reading a single resource instance (HTTP GET on a resource). + * The operation will be mapped to `GET //{id}`. + * + * @param resourceType The resource model type this operation reads. + */ +extern dec reads(target: Operation, resourceType: Model); + +/** + * Mark an operation as creating a new resource in a collection (HTTP POST). + * The operation will be mapped to `POST /`. + * + * @param resourceType The resource model type this operation creates. + */ +extern dec creates(target: Operation, resourceType: Model); + +/** + * Mark an operation as creating or replacing a resource (HTTP PUT). + * The operation will be mapped to `PUT //{id}`. + * + * @param resourceType The resource model type this operation creates or replaces. + */ +extern dec createsOrReplaces(target: Operation, resourceType: Model); + +/** + * Mark an operation as updating a resource (HTTP PATCH). + * The operation will be mapped to `PATCH //{id}`. + * + * @param resourceType The resource model type this operation updates. + */ +extern dec updates(target: Operation, resourceType: Model); + +/** + * Mark an operation as deleting a resource (HTTP DELETE). + * The operation will be mapped to `DELETE //{id}`. + * + * @param resourceType The resource model type this operation deletes. + */ +extern dec deletes(target: Operation, resourceType: Model); + +/** + * Mark an operation as listing resources from a collection (HTTP GET on a collection). + * The operation will be mapped to `GET /`. + * + * @param resourceType The resource model type this operation lists. + */ +extern dec lists(target: Operation, resourceType: Model); + +/** + * Define a custom action on a resource instance. + * Creates an endpoint like `POST //{id}/`. + * + * @param name The URL path segment for the action. If not provided, the operation name is used. + */ +extern dec action(target: Operation, name?: valueof string); + +/** + * Define a custom action on a resource collection. + * Creates an endpoint like `POST //`. + * + * @param resourceType The resource model type this action operates on. + * @param name The URL path segment for the action. If not provided, the operation name is used. + */ +extern dec collectionAction(target: Operation, resourceType: Model, name?: valueof string); + +/** + * Copy the resource key parameters onto a model. + * Used internally by `KeysOf` and `ParentKeysOf` templates. + * + * @param filter If "parent", only copies parent resource keys. + */ +extern dec copyResourceKeyParameters(target: Model, filter?: valueof string); diff --git a/packages/rest-api/lib/main.tsp b/packages/rest-api/lib/main.tsp new file mode 100644 index 00000000000..e015ac0e9bd --- /dev/null +++ b/packages/rest-api/lib/main.tsp @@ -0,0 +1,6 @@ +import "@typespec/http"; +import "./decorators.tsp"; +import "./resources.tsp"; +import "../dist/src/tsp-index.js"; + +using TypeSpec.Http; diff --git a/packages/rest-api/lib/resources.tsp b/packages/rest-api/lib/resources.tsp new file mode 100644 index 00000000000..6c06084087c --- /dev/null +++ b/packages/rest-api/lib/resources.tsp @@ -0,0 +1,158 @@ +using TypeSpec.Http; + +namespace TypeSpec.RestApi; + +/** + * Extracts the key properties from a resource model (properties decorated with `@key`). + * Used to build path parameters for resource operations. + * @template Resource The resource model type. + */ +@doc("Key properties of {name}", Resource) +@copyResourceKeyParameters +model KeysOf {} + +/** + * Extracts the key properties from all ancestor resources in the parent chain. + * Used to build path parameters for nested resource operations. + * @template Resource The resource model type. + */ +@doc("Parent key properties of {name}", Resource) +@copyResourceKeyParameters("parent") +model ParentKeysOf {} + +/** + * Combines the parent keys and instance key for identifying a specific resource instance. + * @template Resource The resource model type. + */ +model ResourceKey { + ...ParentKeysOf; + ...KeysOf; +} + +/** + * Parameters needed to identify a resource collection (parent keys only). + * @template Resource The resource model type. + */ +model CollectionKey { + ...ParentKeysOf; +} + +// ============================================================================ +// Standard CRUD Operation Interfaces +// ============================================================================ + +/** + * Defines a GET operation to read a single resource by its key. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface ReadOperations { + /** + * Read a resource instance by key. + */ + @reads(Resource) + @get + read(...ResourceKey): Resource | Error; +} + +/** + * Defines a POST operation to create a new resource in the collection. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface CreateOperations { + /** + * Create a new resource. + */ + @creates(Resource) + @post + create(...CollectionKey, @body resource: Resource): Resource | Error; +} + +/** + * Defines a PUT operation to create or replace a resource. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface CreateOrReplaceOperations { + /** + * Create or replace a resource. + */ + @createsOrReplaces(Resource) + @put + createOrReplace(...ResourceKey, @body resource: Resource): Resource | Error; +} + +/** + * Defines a PATCH operation to update an existing resource. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface UpdateOperations { + /** + * Update an existing resource. + */ + @updates(Resource) + @patch + update(...ResourceKey, @body resource: Resource): Resource | Error; +} + +/** + * Defines a DELETE operation to remove a resource. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface DeleteOperations { + /** + * Delete a resource. + */ + @deletes(Resource) + @delete + delete(...ResourceKey): void | Error; +} + +/** + * Defines a GET operation to list all resources in a collection. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface ListOperations { + /** + * List resources in the collection. + */ + @lists(Resource) + @get + list(...CollectionKey): Resource[] | Error; +} + +// ============================================================================ +// Composite Operation Interfaces +// ============================================================================ + +/** + * Operations on a single resource instance: read, update, and delete. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface InstanceOperations + extends ReadOperations, + UpdateOperations, + DeleteOperations {} + +/** + * Operations on a resource collection: create and list. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface CollectionOperations + extends CreateOperations, + ListOperations {} + +/** + * Full CRUD operations: create, read, update, delete, and list. + * @template Resource The resource model type. + * @template Error The error response type. + */ +interface CrudOperations + extends InstanceOperations, + CollectionOperations {} diff --git a/packages/rest-api/package.json b/packages/rest-api/package.json new file mode 100644 index 00000000000..147367f6d0c --- /dev/null +++ b/packages/rest-api/package.json @@ -0,0 +1,59 @@ +{ + "name": "@typespec/rest-api", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "TypeSpec library for defining RESTful APIs using standard REST terminology", + "homepage": "https://typespec.io", + "readme": "https://github.com/microsoft/typespec/blob/main/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "bugs": { + "url": "https://github.com/microsoft/typespec/issues" + }, + "keywords": ["typespec", "rest", "api"], + "type": "module", + "main": "dist/src/index.js", + "tspMain": "lib/main.tsp", + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./testing": { + "types": "./dist/src/testing/index.d.ts", + "default": "./dist/src/testing/index.js" + } + }, + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "build": "pnpm gen-extern-signature && tsc -p tsconfig.build.json && pnpm lint-typespec-library", + "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", + "test": "vitest run", + "test:watch": "vitest -w", + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix" + }, + "files": ["lib/*.tsp", "dist/**", "!dist/test/**"], + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^" + }, + "devDependencies": { + "@typespec/compiler": "workspace:~", + "@typespec/http": "workspace:~", + "@typespec/library-linter": "workspace:~", + "@typespec/tspd": "workspace:~", + "@vitest/coverage-v8": "^3.1.4", + "rimraf": "~6.0.1", + "typescript": "~5.8.3", + "vitest": "^3.1.4" + } +} diff --git a/packages/rest-api/src/decorators.ts b/packages/rest-api/src/decorators.ts new file mode 100644 index 00000000000..9416b79c180 --- /dev/null +++ b/packages/rest-api/src/decorators.ts @@ -0,0 +1,445 @@ +import { + DecoratorContext, + getKeyName, + getTypeName, + isErrorType, + isKey, + Model, + ModelProperty, + Operation, + Program, + setTypeSpecNamespace, + Type, +} from "@typespec/compiler"; +import { $path, getOperationVerb, HttpVerb } from "@typespec/http"; +import { + unsafe_DefaultRouteProducer as DefaultRouteProducer, + unsafe_getRouteProducer as getRouteProducer, + unsafe_RouteOptions as RouteOptions, + unsafe_RouteProducerResult as RouteProducerResult, + unsafe_setRouteProducer as setRouteProducer, +} from "@typespec/http/experimental"; +import type { DiagnosticResult } from "@typespec/compiler"; +import type { HttpOperation } from "@typespec/http"; +import type { + ActionDecorator, + CollectionActionDecorator, + CopyResourceKeyParametersDecorator, + CreatesDecorator, + CreatesOrReplacesDecorator, + DeletesDecorator, + ListsDecorator, + ParentResourceDecorator, + ReadsDecorator, + ResourceDecorator, + UpdatesDecorator, +} from "../generated-defs/TypeSpec.RestApi.js"; +import { reportDiagnostic, RestApiStateKeys } from "./lib.js"; + +// ============================================================================= +// Resource key management +// ============================================================================= + +export interface ResourceKey { + resourceType: Model; + keyProperty: ModelProperty; +} + +/** + * Find the key property of a resource model type. Searches direct properties, + * then base model properties. Results are cached for performance. + */ +export function getResourceTypeKey(program: Program, resourceType: Model): ResourceKey | undefined { + let resourceKey: ResourceKey | undefined = program + .stateMap(RestApiStateKeys.resource) + .get(resourceType); + if (resourceKey) { + return resourceKey; + } + + resourceType.properties.forEach((p: ModelProperty) => { + if (isKey(program, p)) { + if (resourceKey) { + reportDiagnostic(program, { + code: "duplicate-key", + format: { resourceName: resourceType.name }, + target: p, + }); + } else { + resourceKey = { resourceType, keyProperty: p }; + program.stateMap(RestApiStateKeys.resource).set(resourceType, resourceKey); + } + } + }); + + if (resourceKey === undefined && resourceType.baseModel !== undefined) { + resourceKey = getResourceTypeKey(program, resourceType.baseModel); + if (resourceKey !== undefined) { + program.stateMap(RestApiStateKeys.resource).set(resourceType, resourceKey); + } + } + + return resourceKey; +} + +// ============================================================================= +// @resource decorator +// ============================================================================= + +export const $resource: ResourceDecorator = (context, entity, collectionName) => { + const key = getResourceTypeKey(context.program, entity); + if (!key) { + reportDiagnostic(context.program, { + code: "resource-missing-key", + format: { modelName: entity.name }, + target: entity, + }); + return; + } + + // Store the collection name as a segment on the key property + context.program.stateMap(RestApiStateKeys.segments).set(key.keyProperty, collectionName); + + // Also store it directly so we can persist across cloning + key.keyProperty.decorators.push({ + decorator: $setSegment, + args: [ + { value: context.program.checker.createLiteralType(collectionName), jsValue: collectionName }, + ], + }); +}; + +// Internal helper to set segment +function $setSegment(context: DecoratorContext, entity: Model | ModelProperty | Operation, name: string) { + context.program.stateMap(RestApiStateKeys.segments).set(entity, name); +} + +setTypeSpecNamespace("Private", $setSegment); + +function getSegment(program: Program, entity: Type): string | undefined { + return program.stateMap(RestApiStateKeys.segments).get(entity); +} + +function getResourceSegment(program: Program, resourceType: Model): string | undefined { + const resourceKey = getResourceTypeKey(program, resourceType); + return resourceKey + ? getSegment(program, resourceKey.keyProperty) + : getSegment(program, resourceType); +} + +// ============================================================================= +// @parentResource decorator +// ============================================================================= + +export function getParentResource(program: Program, resourceType: Model): Model | undefined { + return program.stateMap(RestApiStateKeys.parentResource).get(resourceType); +} + +export const $parentResource: ParentResourceDecorator = (context, entity, parentType) => { + const { program } = context; + + // Check for circular references + const visited = new Set(); + visited.add(entity); + let current: Model | undefined = parentType; + while (current) { + if (visited.has(current)) { + const cycle = [...visited, current].map((x) => getTypeName(x)).join(" -> "); + reportDiagnostic(program, { + code: "circular-parent-resource", + format: { cycle }, + target: entity, + }); + return; + } + visited.add(current); + current = getParentResource(program, current); + } + + program.stateMap(RestApiStateKeys.parentResource).set(entity, parentType); +}; + +// ============================================================================= +// Resource operation decorators +// ============================================================================= + +export type ResourceOperationType = + | "read" + | "create" + | "createOrReplace" + | "update" + | "delete" + | "list"; + +export interface ResourceOperation { + operation: ResourceOperationType; + resourceType: Model; +} + +const resourceOperationToVerb: Record = { + read: "get", + create: "post", + createOrReplace: "put", + update: "patch", + delete: "delete", + list: "get", +}; + +function getResourceOperationHttpVerb( + program: Program, + operation: Operation, +): HttpVerb | undefined { + const resourceOperation = getResourceOperation(program, operation); + return ( + getOperationVerb(program, operation) ?? + (resourceOperation && resourceOperationToVerb[resourceOperation.operation]) ?? + (getActionDetails(program, operation) || getCollectionActionDetails(program, operation) + ? "post" + : undefined) + ); +} + +function resourceRouteProducer( + program: Program, + operation: Operation, + parentSegments: string[], + overloadBase: HttpOperation | undefined, + options: RouteOptions, +): DiagnosticResult { + const paramOptions = { + ...(options?.paramOptions ?? {}), + verbSelector: getResourceOperationHttpVerb, + }; + return DefaultRouteProducer(program, operation, parentSegments, overloadBase, { + ...options, + paramOptions, + }); +} + +function setResourceOperation( + context: DecoratorContext, + entity: Operation, + resourceType: Model, + operation: ResourceOperationType, +) { + if ((resourceType as any).kind === "TemplateParameter") { + return; + } + + context.program.stateMap(RestApiStateKeys.resourceOperations).set(entity, { + operation, + resourceType, + }); + + if (!getRouteProducer(context.program, entity)) { + setRouteProducer(context.program, entity, resourceRouteProducer); + } +} + +/** + * Get the resource operation metadata for an operation. + */ +export function getResourceOperation( + program: Program, + operation: Operation, +): ResourceOperation | undefined { + return program.stateMap(RestApiStateKeys.resourceOperations).get(operation); +} + +/** + * Returns `true` if the given operation is a list operation. + */ +export function isListOperation(program: Program, target: Operation): boolean { + return getResourceOperation(program, target)?.operation === "list"; +} + +// @reads +export const $reads: ReadsDecorator = (context, entity, resourceType) => { + setResourceOperation(context, entity, resourceType, "read"); +}; + +// @creates +export const $creates: CreatesDecorator = (context, entity, resourceType) => { + const segment = getResourceSegment(context.program, resourceType); + if (segment) { + context.program.stateMap(RestApiStateKeys.segments).set(entity, segment); + } + setResourceOperation(context, entity, resourceType, "create"); +}; + +// @createsOrReplaces +export const $createsOrReplaces: CreatesOrReplacesDecorator = (context, entity, resourceType) => { + setResourceOperation(context, entity, resourceType, "createOrReplace"); +}; + +// @updates +export const $updates: UpdatesDecorator = (context, entity, resourceType) => { + setResourceOperation(context, entity, resourceType, "update"); +}; + +// @deletes +export const $deletes: DeletesDecorator = (context, entity, resourceType) => { + setResourceOperation(context, entity, resourceType, "delete"); +}; + +// @lists +export const $lists: ListsDecorator = (context, entity, resourceType) => { + const segment = getResourceSegment(context.program, resourceType); + if (segment) { + context.program.stateMap(RestApiStateKeys.segments).set(entity, segment); + } + setResourceOperation(context, entity, resourceType, "list"); +}; + +// ============================================================================= +// @action and @collectionAction decorators +// ============================================================================= + +export interface ActionDetails { + /** The name of the action */ + name: string; + /** Whether the name was explicitly specified or derived from the operation name */ + kind: "automatic" | "specified"; +} + +function lowerCaseFirstChar(str: string): string { + return str[0].toLocaleLowerCase() + str.substring(1); +} + +function makeActionName(op: Operation, name: string | undefined): ActionDetails { + return { + name: lowerCaseFirstChar(name || op.name), + kind: name ? "specified" : "automatic", + }; +} + +export const $action: ActionDecorator = (context, entity, name?) => { + if (name === "") { + reportDiagnostic(context.program, { + code: "invalid-action-name", + target: entity, + }); + return; + } + + const action = makeActionName(entity, name); + context.program.stateMap(RestApiStateKeys.actionSegment).set(entity, action.name); + context.program.stateMap(RestApiStateKeys.actions).set(entity, action); +}; + +/** + * Gets the ActionDetails for an operation marked with @action. + */ +export function getActionDetails( + program: Program, + operation: Operation, +): ActionDetails | undefined { + return program.stateMap(RestApiStateKeys.actions).get(operation); +} + +export const $collectionAction: CollectionActionDecorator = ( + context, + entity, + resourceType, + name?, +) => { + if ((resourceType as Type).kind === "TemplateParameter") { + return; + } + + const segment = getResourceSegment(context.program, resourceType); + if (segment) { + context.program.stateMap(RestApiStateKeys.segments).set(entity, segment); + } + + const action = makeActionName(entity, name); + context.program.stateMap(RestApiStateKeys.actionSegment).set(entity, action.name); + + action.name = `${segment}/${action.name}`; + context.program.stateMap(RestApiStateKeys.collectionActions).set(entity, action); +}; + +/** + * Gets the ActionDetails for an operation marked with @collectionAction. + */ +export function getCollectionActionDetails( + program: Program, + operation: Operation, +): ActionDetails | undefined { + return program.stateMap(RestApiStateKeys.collectionActions).get(operation); +} + +// ============================================================================= +// @copyResourceKeyParameters (internal decorator for templates) +// ============================================================================= + +const VISIBILITY_DECORATORS_NAMES = new Set(["$visibility", "$invisible", "$removeVisibility"]); + +function cloneKeyProperties(context: DecoratorContext, target: Model, resourceType: Model) { + const { program } = context; + const parentType = getParentResource(program, resourceType); + if (parentType) { + cloneKeyProperties(context, target, parentType); + } + + const resourceKey = getResourceTypeKey(program, resourceType); + if (resourceKey) { + const { keyProperty } = resourceKey; + const keyName = getKeyName(program, keyProperty)!; + + const decorators = [ + ...keyProperty.decorators.filter( + (d) => !VISIBILITY_DECORATORS_NAMES.has(d.decorator.name), + ), + ]; + + if (!keyProperty.decorators.some((d) => d.decorator.name === $path.name)) { + decorators.push({ decorator: $path, args: [] }); + } + + const newProp = program.checker.cloneType(keyProperty, { + name: keyName, + decorators, + optional: false, + model: target, + sourceProperty: undefined, + }); + + target.properties.set(keyName, newProp); + } +} + +export const $copyResourceKeyParameters: CopyResourceKeyParametersDecorator = ( + context, + entity, + filter?, +) => { + const reportNoKeyError = () => + reportDiagnostic(context.program, { + code: "not-key-type", + target: entity, + }); + + const templateArguments = entity.templateMapper?.args; + if (!templateArguments || templateArguments.length !== 1) { + return reportNoKeyError(); + } + + if ((templateArguments[0] as any).kind !== "Model") { + if (isErrorType(templateArguments[0])) { + return; + } + return reportNoKeyError(); + } + + const resourceType = templateArguments[0] as Model; + + if (filter === "parent") { + const parentType = getParentResource(context.program, resourceType); + if (parentType) { + cloneKeyProperties(context, entity, parentType); + } + } else { + cloneKeyProperties(context, entity, resourceType); + } +}; diff --git a/packages/rest-api/src/index.ts b/packages/rest-api/src/index.ts new file mode 100644 index 00000000000..de34a7f27df --- /dev/null +++ b/packages/rest-api/src/index.ts @@ -0,0 +1,29 @@ +export { $lib } from "./lib.js"; +export { + $resource, + $parentResource, + $reads, + $creates, + $createsOrReplaces, + $updates, + $deletes, + $lists, + $action, + $collectionAction, + $copyResourceKeyParameters, + getResourceTypeKey, + getParentResource, + getResourceOperation, + isListOperation, + getActionDetails, + getCollectionActionDetails, +} from "./decorators.js"; + +export type { + ResourceKey, + ResourceOperation, + ResourceOperationType, + ActionDetails, +} from "./decorators.js"; + +export { $decorators } from "./tsp-index.js"; diff --git a/packages/rest-api/src/lib.ts b/packages/rest-api/src/lib.ts new file mode 100644 index 00000000000..db0b2385ab3 --- /dev/null +++ b/packages/rest-api/src/lib.ts @@ -0,0 +1,48 @@ +import { createTypeSpecLibrary } from "@typespec/compiler"; + +export const $lib = createTypeSpecLibrary({ + name: "@typespec/rest-api", + diagnostics: { + "resource-missing-key": { + severity: "error", + messages: { + default: `Model type '{modelName}' is used as a resource but does not have a property decorated with @key.`, + }, + }, + "duplicate-key": { + severity: "error", + messages: { + default: `Resource type '{resourceName}' has more than one property decorated with @key.`, + }, + }, + "circular-parent-resource": { + severity: "error", + messages: { + default: `Resource type has a circular parent relationship: {cycle}`, + }, + }, + "invalid-action-name": { + severity: "error", + messages: { + default: `Action name cannot be empty.`, + }, + }, + "not-key-type": { + severity: "error", + messages: { + default: `Template argument must be a model type with a @key property.`, + }, + }, + }, + state: { + resource: { description: "State for @resource decorator" }, + parentResource: { description: "State for @parentResource decorator" }, + resourceOperations: { description: "State for resource operation decorators" }, + actions: { description: "State for @action decorator" }, + collectionActions: { description: "State for @collectionAction decorator" }, + actionSegment: { description: "State for action segment tracking" }, + segments: { description: "State for URL segment tracking" }, + }, +} as const); + +export const { reportDiagnostic, createDiagnostic, stateKeys: RestApiStateKeys } = $lib; diff --git a/packages/rest-api/src/testing/index.ts b/packages/rest-api/src/testing/index.ts new file mode 100644 index 00000000000..c6b680b7811 --- /dev/null +++ b/packages/rest-api/src/testing/index.ts @@ -0,0 +1,6 @@ +import { createTestLibrary, findTestPackageRoot, TypeSpecTestLibrary } from "@typespec/compiler/testing"; + +export const RestApiTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@typespec/rest-api", + packageRoot: await findTestPackageRoot(import.meta.url), +}); diff --git a/packages/rest-api/src/tsp-index.ts b/packages/rest-api/src/tsp-index.ts new file mode 100644 index 00000000000..573da8d3afa --- /dev/null +++ b/packages/rest-api/src/tsp-index.ts @@ -0,0 +1,29 @@ +import { + $action, + $collectionAction, + $copyResourceKeyParameters, + $creates, + $createsOrReplaces, + $deletes, + $lists, + $parentResource, + $reads, + $resource, + $updates, +} from "./decorators.js"; + +export const $decorators = { + "TypeSpec.RestApi": { + resource: $resource, + parentResource: $parentResource, + reads: $reads, + creates: $creates, + createsOrReplaces: $createsOrReplaces, + updates: $updates, + deletes: $deletes, + lists: $lists, + action: $action, + collectionAction: $collectionAction, + copyResourceKeyParameters: $copyResourceKeyParameters, + }, +}; diff --git a/packages/rest-api/tsconfig.build.json b/packages/rest-api/tsconfig.build.json new file mode 100644 index 00000000000..b51f88471c8 --- /dev/null +++ b/packages/rest-api/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "generated-defs/**/*.ts", "test/**/*.ts"], + "references": [ + { "path": "../compiler/tsconfig.build.json" }, + { "path": "../http/tsconfig.build.json" } + ] +} diff --git a/packages/rest-api/tsconfig.json b/packages/rest-api/tsconfig.json new file mode 100644 index 00000000000..e6bf4da603f --- /dev/null +++ b/packages/rest-api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{ "path": "../compiler/tsconfig.json" }, { "path": "../http/tsconfig.json" }], + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo" + } +} diff --git a/packages/rest-api/vitest.config.ts b/packages/rest-api/vitest.config.ts new file mode 100644 index 00000000000..63cad767f57 --- /dev/null +++ b/packages/rest-api/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ac27e3349..c2912dac35b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1812,6 +1812,33 @@ importers: specifier: ^4.0.15 version: 4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/rest-api: + devDependencies: + '@typespec/compiler': + specifier: workspace:~ + version: link:../compiler + '@typespec/http': + specifier: workspace:~ + version: link:../http + '@typespec/library-linter': + specifier: workspace:~ + version: link:../library-linter + '@typespec/tspd': + specifier: workspace:~ + version: link:../tspd + '@vitest/coverage-v8': + specifier: ^3.1.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2)) + rimraf: + specifier: ~6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vitest: + specifier: ^3.1.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/samples: dependencies: '@typespec/best-practices': @@ -2745,6 +2772,10 @@ packages: '@alloy-js/typescript@0.22.0': resolution: {integrity: sha512-jARBNxAA5aEhysleFFd7cGfjckkEXLCH9kDaJSH5xBOu4cU0v7q5TvAqgPlEIkhfOh2983XLX0nVtZu01p0UjQ==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -6517,6 +6548,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/coverage-v8@4.0.17': resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==} peerDependencies: @@ -6573,9 +6613,15 @@ packages: '@vitest/pretty-format@4.0.17': resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.17': resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.17': resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} @@ -7464,6 +7510,10 @@ packages: monocart-coverage-reports: optional: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@19.0.1: resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -9099,12 +9149,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} @@ -9814,6 +9864,10 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} @@ -10146,6 +10200,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.1: resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==} @@ -11738,6 +11795,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + rimraf@6.1.2: resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} engines: {node: 20 || >=22} @@ -12258,6 +12320,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} @@ -12421,6 +12486,9 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -12432,6 +12500,10 @@ packages: tinylogic@2.0.0: resolution: {integrity: sha512-dljTkiLLITtsjqBvTA1MRZQK/sGP4kI3UJKc3yA9fMzYbMF2RhcN04SeROVqJBIYYOoJMM8u0WDnhFwMSFQotw==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -12708,6 +12780,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -13002,6 +13079,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-checker@0.12.0: resolution: {integrity: sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==} engines: {node: '>=16.11'} @@ -13141,6 +13223,34 @@ packages: vite: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.17: resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -13754,6 +13864,11 @@ snapshots: change-case: 5.4.4 pathe: 2.0.3 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -19005,6 +19120,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.10 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@4.0.17(vitest@4.0.17)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -19071,11 +19205,23 @@ snapshots: dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.17': dependencies: '@vitest/utils': 4.0.17 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.17': dependencies: '@vitest/pretty-format': 4.0.17 @@ -20431,6 +20577,8 @@ snapshots: yargs: 17.7.2 yargs-parser: 21.1.1 + cac@6.7.14: {} + cacache@19.0.1: dependencies: '@npmcli/fs': 4.0.0 @@ -23281,6 +23429,14 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 @@ -23663,6 +23819,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + source-map-js: 1.2.1 + magicast@0.5.1: dependencies: '@babel/parser': 7.28.6 @@ -25810,6 +25972,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.0.1: + dependencies: + glob: 11.1.0 + package-json-from-dist: 1.0.1 + rimraf@6.1.2: dependencies: glob: 13.0.0 @@ -26460,6 +26627,10 @@ snapshots: strip-json-comments@5.0.3: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@2.1.2: {} structured-source@4.0.0: @@ -26695,6 +26866,8 @@ snapshots: tinybench@2.9.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -26704,6 +26877,8 @@ snapshots: tinylogic@2.0.0: {} + tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.0.3: {} @@ -26954,6 +27129,8 @@ snapshots: typescript@5.8.2: {} + typescript@5.8.3: {} + typescript@5.9.3: {} typical@4.0.0: {} @@ -27201,6 +27378,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-checker@0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@babel/code-frame': 7.28.6 @@ -27276,6 +27474,51 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2) + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.0.9)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.0.9 + '@vitest/ui': 4.0.17(vitest@4.0.17) + happy-dom: 20.3.4 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.17(@types/node@25.0.9)(@vitest/ui@4.0.17)(happy-dom@20.3.4)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 From 97160e23b34e37755a17ee37e2ff1800fddbdb70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:17:11 +0000 Subject: [PATCH 3/3] test: add unit tests for @typespec/rest-api decorators and templates 19 tests covering: @resource, @parentResource, resource operation decorators (@reads, @creates, @updates, @deletes, @lists, @createsOrReplaces), @action validation, and CRUD operation template interfaces. Co-authored-by: johanste <15110018+johanste@users.noreply.github.com> --- packages/rest-api/test/decorators.test.ts | 328 ++++++++++++++++++++++ packages/rest-api/test/test-host.ts | 89 ++++++ 2 files changed, 417 insertions(+) create mode 100644 packages/rest-api/test/decorators.test.ts create mode 100644 packages/rest-api/test/test-host.ts diff --git a/packages/rest-api/test/decorators.test.ts b/packages/rest-api/test/decorators.test.ts new file mode 100644 index 00000000000..762dcb44700 --- /dev/null +++ b/packages/rest-api/test/decorators.test.ts @@ -0,0 +1,328 @@ +import { expectDiagnostics, t } from "@typespec/compiler/testing"; +import { deepStrictEqual, ok, strictEqual } from "assert"; +import { describe, it } from "vitest"; +import { getResourceTypeKey, getParentResource, getResourceOperation } from "../src/decorators.js"; +import { Tester, compileOperations, getRoutesFor } from "./test-host.js"; + +describe("rest-api: @resource decorator", () => { + it("emits a diagnostic when a @key property is not found", async () => { + const diagnostics = await Tester.diagnose(` + @resource("things") + model Thing { + id: string; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/rest-api/resource-missing-key", + }); + }); + + it("applies segment to key property", async () => { + const { Thing, program } = await Tester.compile(t.code` + @resource("things") + model ${t.model("Thing")} { + @key + id: string; + } + `); + + const key = getResourceTypeKey(program, Thing); + ok(key, "No key property found."); + strictEqual(key.keyProperty.name, "id"); + }); + + it("finds key in base model", async () => { + const { Thing, program } = await Tester.compile(t.code` + model BaseThing { + @key + id: string; + } + + @resource("things") + model ${t.model("Thing")} extends BaseThing { + extra: string; + } + `); + + const key = getResourceTypeKey(program, Thing); + ok(key, "No key property found."); + }); + + it("reports duplicate key", async () => { + const diagnostics = await Tester.diagnose(` + @resource("things") + model Thing { + @key id: string; + @key name: string; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/rest-api/duplicate-key", + }); + }); +}); + +describe("rest-api: @parentResource decorator", () => { + it("sets parent resource", async () => { + const { Child, Parent, program } = await Tester.compile(t.code` + @resource("parents") + model ${t.model("Parent")} { + @key parentId: string; + } + + @resource("children") + @parentResource(Parent) + model ${t.model("Child")} { + @key childId: string; + } + `); + + const parent = getParentResource(program, Child); + ok(parent); + strictEqual(parent, Parent); + }); + + it("detects circular parent resource", async () => { + const diagnostics = await Tester.diagnose(` + @service namespace My; + + @resource("A") + @parentResource(A) + model A { @key a: string } + `); + + expectDiagnostics(diagnostics, [ + { + code: "@typespec/rest-api/circular-parent-resource", + }, + ]); + }); + + it("detects circular parents across multiple resources", async () => { + const diagnostics = await Tester.diagnose(` + @service namespace My; + + @resource("A") + @parentResource(B) + model A { @key a: string } + + @resource("B") + @parentResource(A) + model B { @key b: string } + `); + + const circularDiags = diagnostics.filter( + (d) => d.code === "@typespec/rest-api/circular-parent-resource", + ); + ok(circularDiags.length >= 1, "Expected at least one circular parent diagnostic"); + }); +}); + +describe("rest-api: resource operation decorators", () => { + it("@reads sets read operation", async () => { + const { read, program } = await Tester.compile(t.code` + @resource("things") + model Thing { + @key id: string; + } + + @reads(Thing) + op ${t.op("read")}(@path id: string): Thing; + `); + + const resOp = getResourceOperation(program, read); + ok(resOp); + strictEqual(resOp.operation, "read"); + }); + + it("@creates sets create operation", async () => { + const { create, program } = await Tester.compile(t.code` + @resource("things") + model Thing { + @key id: string; + } + + @creates(Thing) + op ${t.op("create")}(@body thing: Thing): Thing; + `); + + const resOp = getResourceOperation(program, create); + ok(resOp); + strictEqual(resOp.operation, "create"); + }); + + it("@updates sets update operation", async () => { + const [result, diagnostics] = await Tester.compileAndDiagnose(t.code` + @resource("things") + model Thing { + @key id: string; + } + + @updates(Thing) + op ${t.op("update")}(@path id: string, @body thing: Thing): Thing; + `); + + const resOp = getResourceOperation(result.program, result.update); + ok(resOp); + strictEqual(resOp.operation, "update"); + }); + + it("@deletes sets delete operation", async () => { + const { del, program } = await Tester.compile(t.code` + @resource("things") + model Thing { + @key id: string; + } + + @deletes(Thing) + op ${t.op("del")}(@path id: string): void; + `); + + const resOp = getResourceOperation(program, del); + ok(resOp); + strictEqual(resOp.operation, "delete"); + }); + + it("@lists sets list operation", async () => { + const { listThings, program } = await Tester.compile(t.code` + @resource("things") + model Thing { + @key id: string; + } + + @lists(Thing) + op ${t.op("listThings")}(): Thing[]; + `); + + const resOp = getResourceOperation(program, listThings); + ok(resOp); + strictEqual(resOp.operation, "list"); + }); + + it("@createsOrReplaces sets createOrReplace operation", async () => { + const { replace, program } = await Tester.compile(t.code` + @resource("things") + model Thing { + @key id: string; + } + + @createsOrReplaces(Thing) + op ${t.op("replace")}(@path id: string, @body thing: Thing): Thing; + `); + + const resOp = getResourceOperation(program, replace); + ok(resOp); + strictEqual(resOp.operation, "createOrReplace"); + }); +}); + +describe("rest-api: @action decorator", () => { + it("emits diagnostic for empty action name", async () => { + const diagnostics = await Tester.diagnose(` + @resource("things") + model Thing { + @key id: string; + } + + @action("") + op doSomething(): void; + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/rest-api/invalid-action-name", + }); + }); +}); + +describe("rest-api: CRUD operation templates", () => { + it("ReadOperations generates GET on resource instance", async () => { + const [ops, diagnostics] = await compileOperations(` + @resource("things") + model Thing { + @key("thingId") + id: string; + name: string; + } + + @error model Error {} + + interface Things extends ReadOperations {} + `); + + strictEqual(ops.length, 1); + strictEqual(ops[0].verb, "get"); + }); + + it("CreateOperations generates POST on collection", async () => { + const [ops, diagnostics] = await compileOperations(` + @resource("things") + model Thing { + @key("thingId") + id: string; + name: string; + } + + @error model Error {} + + interface Things extends CreateOperations {} + `); + + strictEqual(ops.length, 1); + strictEqual(ops[0].verb, "post"); + }); + + it("DeleteOperations generates DELETE on resource instance", async () => { + const [ops, diagnostics] = await compileOperations(` + @resource("things") + model Thing { + @key("thingId") + id: string; + } + + @error model Error {} + + interface Things extends DeleteOperations {} + `); + + strictEqual(ops.length, 1); + strictEqual(ops[0].verb, "delete"); + }); + + it("ListOperations generates GET on collection", async () => { + const [ops, diagnostics] = await compileOperations(` + @resource("things") + model Thing { + @key("thingId") + id: string; + } + + @error model Error {} + + interface Things extends ListOperations {} + `); + + strictEqual(ops.length, 1); + strictEqual(ops[0].verb, "get"); + }); + + it("CrudOperations generates all CRUD operations", async () => { + const [ops, diagnostics] = await compileOperations(` + @resource("things") + model Thing { + @key("thingId") + id: string; + name: string; + } + + @error model Error {} + + interface Things extends CrudOperations {} + `); + + strictEqual(ops.length, 5); + const verbs = ops.map((o) => o.verb).sort(); + deepStrictEqual(verbs, ["delete", "get", "get", "patch", "post"]); + }); +}); diff --git a/packages/rest-api/test/test-host.ts b/packages/rest-api/test/test-host.ts new file mode 100644 index 00000000000..b41514c4b51 --- /dev/null +++ b/packages/rest-api/test/test-host.ts @@ -0,0 +1,89 @@ +import type { Diagnostic } from "@typespec/compiler"; +import { resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { + getAllHttpServices, + HttpOperation, + HttpOperationParameter, + HttpVerb, +} from "@typespec/http"; +import { unsafe_RouteResolutionOptions as RouteResolutionOptions } from "@typespec/http/experimental"; + +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/http", "@typespec/rest-api"], +}) + .importLibraries() + .using("Http", "RestApi"); + +export interface RouteDetails { + path: string; + verb: HttpVerb; + params: string[]; +} + +export async function getRoutesFor( + code: string, + routeOptions?: RouteResolutionOptions, +): Promise { + const [routes, diagnostics] = await compileOperations(code, routeOptions); + expectDiagnosticEmpty(diagnostics); + return routes.map((route) => ({ + ...route, + params: route.params.params + .map(({ type, name }) => (type === "path" ? name : undefined)) + .filter((p) => p !== undefined) as string[], + })); +} + +export interface SimpleOperationDetails { + verb: HttpVerb; + path: string; + params: { + params: Array<{ name: string; type: HttpOperationParameter["type"] }>; + body?: string | string[]; + }; +} + +export async function compileOperations( + code: string, + routeOptions?: RouteResolutionOptions, +): Promise<[SimpleOperationDetails[], readonly Diagnostic[]]> { + const [routes, diagnostics] = await getOperationsWithServiceNamespace(code, routeOptions); + + const details = routes.map((r) => { + return { + verb: r.verb, + path: r.path, + params: { + params: r.parameters.parameters.map(({ type, name }) => ({ type, name })), + body: + r.parameters.body?.property?.name ?? + (r.parameters.body?.type.kind === "Model" + ? Array.from(r.parameters.body?.type.properties.keys()) + : undefined), + }, + }; + }); + + return [details, diagnostics]; +} + +export async function getOperationsWithServiceNamespace( + code: string, + routeOptions?: RouteResolutionOptions, +): Promise<[HttpOperation[], readonly Diagnostic[]]> { + const [result, diagnostics] = await Tester.compileAndDiagnose( + `@service(#{title: "Test Service"}) namespace TestService; + ${code}`, + ); + const [services] = getAllHttpServices(result.program, routeOptions); + return [services[0].operations, diagnostics]; +} + +export async function getOperations(code: string): Promise { + const { program } = await Tester.compile(code); + const [services, diagnostics] = getAllHttpServices(program); + + expectDiagnosticEmpty(diagnostics); + return services[0].operations; +}