diff --git a/.chronus/changes/promote-use-model-request-body-2026-08-31.md b/.chronus/changes/promote-use-model-request-body-2026-08-31.md new file mode 100644 index 0000000000..43ad246652 --- /dev/null +++ b/.chronus/changes/promote-use-model-request-body-2026-08-31.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-resource-manager" +--- + +Add the `use-model-request-body` ARM lint rule, an idiomatic TypeSpec migration of the Swagger `ParametersSchemaAsTypeObject` validator rule. diff --git a/.chronus/changes/register-use-model-request-body-ruleset.md b/.chronus/changes/register-use-model-request-body-ruleset.md new file mode 100644 index 0000000000..7e7ab3b6eb --- /dev/null +++ b/.chronus/changes/register-use-model-request-body-ruleset.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@azure-tools/typespec-azure-rulesets" +--- + +Register the ARM `use-model-request-body` lint rule as disabled in the resource manager ruleset. diff --git a/packages/typespec-azure-resource-manager/README.md b/packages/typespec-azure-resource-manager/README.md index aad67843c9..45af72fa73 100644 --- a/packages/typespec-azure-resource-manager/README.md +++ b/packages/typespec-azure-resource-manager/README.md @@ -75,6 +75,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-resource-manager/resource-name`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/resource-name) | Check the resource name. | | [`@azure-tools/typespec-azure-resource-manager/retry-after`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/retry-after) | Check if retry-after header appears in response body. | | [`@azure-tools/typespec-azure-resource-manager/unsupported-type`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/unsupported-type) | Check for unsupported ARM types. | +| [`@azure-tools/typespec-azure-resource-manager/use-model-request-body`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-model-request-body) | Request bodies must use plain models. | | [`@azure-tools/typespec-azure-resource-manager/secret-prop`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/secret-prop) | RPC-v1-13: Check that property with names indicating sensitive information(e.g. contains auth, password, token, secret, etc.) are marked with @secret decorator. | | [`@azure-tools/typespec-azure-resource-manager/no-empty-model`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-empty-model) | ARM Properties with type:object that don't reference a model definition are not allowed. ARM doesn't allow generic type definitions as this leads to bad customer experience. | | [`@azure-tools/typespec-azure-resource-manager/no-reserved-resource-property`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-reserved-resource-property) | Reserved property names (for example 'billingData') must not be present in a resource's property bag. The property name is matched case-insensitively. | diff --git a/packages/typespec-azure-resource-manager/src/linter.ts b/packages/typespec-azure-resource-manager/src/linter.ts index efef6bbc97..4333e5357a 100644 --- a/packages/typespec-azure-resource-manager/src/linter.ts +++ b/packages/typespec-azure-resource-manager/src/linter.ts @@ -48,6 +48,7 @@ import { unsupportedTypeRule } from "./rules/unsupported-type.js"; import { useApiVersionRule } from "./rules/use-api-version.js"; import { useApplicationJsonContentTypeRule } from "./rules/use-application-json-content-type.js"; import { useInterfaceRule } from "./rules/use-interface.js"; +import { useModelRequestBodyRule } from "./rules/use-model-request-body.js"; import { useOperationDecoratorRule } from "./rules/use-operation-decorator.js"; import { useRelationshipRequiredPropertiesRule } from "./rules/use-relationship-required-properties.js"; import { versionProgressionRule } from "./rules/version-progression.js"; @@ -100,6 +101,7 @@ const rules = [ resourceNameRule, retryAfterRule, unsupportedTypeRule, + useModelRequestBodyRule, secretProprule, noEmptyModel, noReservedResourcePropertyRule, diff --git a/packages/typespec-azure-resource-manager/src/rules/use-model-request-body.md b/packages/typespec-azure-resource-manager/src/rules/use-model-request-body.md new file mode 100644 index 0000000000..bf856f3b80 --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/use-model-request-body.md @@ -0,0 +1,52 @@ +Use a plain model for every Azure Resource Manager request body. + +## Impact + +- **Area:** API, SDK + +Plain model request bodies can evolve by adding optional properties without changing the top-level wire shape. Primitive, union, array, and record bodies cannot gain new fields without a breaking API and generated-SDK change. + +A plain model is a TypeSpec model without an indexer. This rule evaluates the authored TypeSpec shape rather than reproducing emitter-specific Swagger schema behavior. Operations without a request body and multipart request bodies are allowed. + +## Incorrect + +```tsp +@post +op submit(@body body: string): void; + +model ItemList is Array; + +@post +op submitItems(@body body: ItemList): void; + +model Metadata is Record; + +@post +op submitMetadata(@body body: Metadata): void; +``` + +## Correct + +```tsp +model SubmitRequest { + value: string; +} + +@post +op submit(@body body: SubmitRequest): void; + +model SubmitItemsRequest { + items: string[]; +} + +@post +op submitItems(@body body: SubmitItemsRequest): void; +``` + +## Suppression + +Suppress only when required to preserve an existing API; otherwise replace the request body with a model without an indexer. + +## LintDiff Origin + +This rule is the idiomatic TypeSpec equivalent of the Swagger validator rule [ParametersSchemaAsTypeObject](https://github.com/Azure/azure-openapi-validator/blob/main/docs/parameters-schema-as-type-object.md). It intentionally validates TypeSpec model semantics instead of simulating AutoRest's emitted Swagger schema details. diff --git a/packages/typespec-azure-resource-manager/src/rules/use-model-request-body.ts b/packages/typespec-azure-resource-manager/src/rules/use-model-request-body.ts new file mode 100644 index 0000000000..1c60cfe8c1 --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/use-model-request-body.ts @@ -0,0 +1,48 @@ +import { createRule, fileRef, getLocationContext, isVoidType, type Type } from "@typespec/compiler"; +import { getHttpOperation } from "@typespec/http"; + +export const useModelRequestBodyRule = createRule({ + name: "use-model-request-body", + docs: fileRef.fromPackageRoot("src/rules/use-model-request-body.md"), + description: "Request bodies must use plain models.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-model-request-body", + messages: { + default: + "Request bodies must use plain models. Replace this body type with a model without an indexer.", + }, + create(context) { + return { + operation: (operation) => { + const [httpOperation] = getHttpOperation(context.program, operation); + const body = httpOperation.parameters.body; + if (body === undefined || body.bodyKind === "multipart") { + return; + } + + const bodyType = getUnderlyingType(body.type); + if (isVoidType(bodyType) || (body.bodyKind === "single" && isPlainModel(bodyType))) { + return; + } + + context.reportDiagnostic({ + target: + body.property && getLocationContext(context.program, body.property).type === "project" + ? body.property + : operation, + }); + }, + }; + }, +}); + +function getUnderlyingType(type: Type): Type { + while (type.kind === "ModelProperty") { + type = type.type; + } + return type; +} + +function isPlainModel(type: Type): boolean { + return type.kind === "Model" && type.indexer === undefined; +} diff --git a/packages/typespec-azure-resource-manager/test/rules/use-model-request-body.test.ts b/packages/typespec-azure-resource-manager/test/rules/use-model-request-body.test.ts new file mode 100644 index 0000000000..18f070a03d --- /dev/null +++ b/packages/typespec-azure-resource-manager/test/rules/use-model-request-body.test.ts @@ -0,0 +1,258 @@ +import { Tester } from "#test/tester.js"; +import { + type LinterRuleTester, + type TesterInstance, + createLinterRuleTester, +} from "@typespec/compiler/testing"; +import { beforeEach, it } from "vitest"; + +import { useModelRequestBodyRule } from "../../src/rules/use-model-request-body.js"; + +let tester: LinterRuleTester; + +beforeEach(async () => { + const runner: TesterInstance = await Tester.createInstance(); + tester = createLinterRuleTester( + runner, + useModelRequestBodyRule, + "@azure-tools/typespec-azure-resource-manager", + ); +}); + +const diagnostic = { + code: "@azure-tools/typespec-azure-resource-manager/use-model-request-body", + message: + "Request bodies must use plain models. Replace this body type with a model without an indexer.", +}; + +it("reports primitive request bodies", async () => { + await tester + .expect( + ` + @route("/post") @post op submit(@body body: string): void; + @route("/put") @put op update(@body body: int32): void; + `, + ) + .toEmitDiagnostics([diagnostic, diagnostic]); +}); + +it("reports non-model request body types", async () => { + await tester + .expect( + ` + scalar OpaqueRequest; + + enum RequestMode { + fast, + } + + union RequestChoice { + first: { first: string }, + second: { second: string }, + } + + @route("/scalar") @post op submitScalar(@body body: OpaqueRequest): void; + @route("/enum") @post op submitEnum(@body body: RequestMode): void; + @route("/union") @post op submitUnion(@body body: RequestChoice): void; + @route("/unknown") @post op submitUnknown(@body body: unknown): void; + @route("/tuple") @post op submitTuple(@body body: [string, string]): void; + @route("/literal") @post op submitLiteral(@body body: "fixed"): void; + `, + ) + .toEmitDiagnostics(Array.from({ length: 6 }, () => diagnostic)); +}); + +it("reports array and record request bodies", async () => { + await tester + .expect( + ` + model StringList is Array; + model StringMap is Record; + + @route("/array") @post op submitArray(@body body: string[]): void; + @route("/named-array") @post op submitNamedArray(@body body: StringList): void; + @route("/record") @post op submitRecord(@body body: Record): void; + @route("/named-record") @post op submitNamedRecord(@body body: StringMap): void; + `, + ) + .toEmitDiagnostics(Array.from({ length: 4 }, () => diagnostic)); +}); + +it("reports a named array model used by an ARM action", async () => { + await tester + .expect( + ` + @armProviderNamespace + @service(#{ title: "Test service" }) + @versioned(Versions) + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + namespace Microsoft.Test; + + enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v5) + v2024_01_01: "2024-01-01", + } + + model StringList is Array; + + model Widget is TrackedResource { + ...ResourceNameParameter< + Resource = Widget, + KeyName = "widgetName", + SegmentName = "widgets", + NamePattern = "" + >; + } + + model WidgetProperties { + @visibility(Lifecycle.Read) + provisioningState?: ResourceProvisioningState; + } + + interface Operations extends Azure.ResourceManager.Operations {} + + @armResourceOperations + interface Widgets { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmResourcePatchAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + + @action("submitItems") + submitItems is ArmResourceActionSync>; + } + `, + ) + .toEmitDiagnostics(diagnostic); +}); + +it("allows named and inline model request bodies", async () => { + await tester + .expect( + ` + model SubmitRequest { + value: string; + } + + @route("/named") @post op submit(@body body: SubmitRequest): void; + @route("/inline") @post op submitInline(@body body: { value: string }): void; + `, + ) + .toBeValid(); +}); + +it("allows a model property that references a model", async () => { + await tester + .expect( + ` + model SubmitRequest { + value: string; + } + + model RequestTypes { + submit: SubmitRequest; + } + + @post + op submit(@body body: RequestTypes.submit): void; + `, + ) + .toBeValid(); +}); + +it("allows a plain model containing collection properties", async () => { + await tester + .expect( + ` + model SubmitRequest { + items: string[]; + metadata: Record; + } + + @post + op submit(@body body: SubmitRequest): void; + `, + ) + .toBeValid(); +}); + +it("allows an ARM action with a synthetic void request body", async () => { + await tester + .expect( + ` + @armProviderNamespace + @service(#{ title: "Test service" }) + @versioned(Versions) + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v6) + namespace Microsoft.Test; + + enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v6) + v2024_01_01: "2024-01-01", + } + + model Widget is TrackedResource { + ...ResourceNameParameter< + Resource = Widget, + KeyName = "widgetName", + SegmentName = "widgets", + NamePattern = "" + >; + } + + model WidgetProperties { + @visibility(Lifecycle.Read) + provisioningState?: ResourceProvisioningState; + } + + interface Operations extends Azure.ResourceManager.Operations {} + + @armResourceOperations + interface Widgets { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmResourcePatchAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + + @action("run") + run is ArmResourceActionSync>; + } + `, + ) + .toBeValid(); +}); + +it("reports file request bodies", async () => { + await tester + .expect( + ` + model UploadRequest extends TypeSpec.Http.File { + contentType: "application/octet-stream"; + } + + @post + op upload(@bodyRoot body: UploadRequest): void; + `, + ) + .toEmitDiagnostics(diagnostic); +}); + +it("allows multipart request bodies", async () => { + await tester + .expect( + ` + model UploadForm { + name: HttpPart; + contents: HttpPart; + } + + @post op upload( + @header contentType: "multipart/form-data", + @multipartBody body: UploadForm, + ): void; + `, + ) + .toBeValid(); +}); diff --git a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts index 35a8d04d69..b04bddedb8 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts @@ -108,6 +108,7 @@ export default { "@azure-tools/typespec-azure-resource-manager/secret-prop": true, "@azure-tools/typespec-azure-resource-manager/unsupported-type": true, "@azure-tools/typespec-azure-resource-manager/no-query-in-point-op": false, + "@azure-tools/typespec-azure-resource-manager/use-model-request-body": false, // TCGC rules "@azure-tools/typespec-client-generator-core/require-client-suffix": true, diff --git a/website/src/content/docs/docs/howtos/ARM/arm-rules.md b/website/src/content/docs/docs/howtos/ARM/arm-rules.md index d3209ab6c2..6282613850 100644 --- a/website/src/content/docs/docs/howtos/ARM/arm-rules.md +++ b/website/src/content/docs/docs/howtos/ARM/arm-rules.md @@ -134,6 +134,7 @@ The tables below provide guidance to rule authors and ARM reviewers on how to ev | [`unsupported-type`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/unsupported-type/) | — | **SDK.** The data type cannot be modeled in SDKs. | | [`use-api-version`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-api-version/) | [`ApiVersionParameterRequired`](https://github.com/Azure/azure-openapi-validator/blob/main/docs/api-version-parameter-required.md) | **API, SDK.** An operation without the standard API version parameter cannot evolve safely and is difficult for SDKs to represent consistently. | | [`use-interface`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-interface/) | — | **API, SDK, Emitters.** Resource operations outside interfaces can break ARM resource-operation modeling and downstream tooling assumptions. | +| [`use-model-request-body`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-model-request-body/) | [`ParametersSchemaAsTypeObject`](https://github.com/Azure/azure-openapi-validator/blob/main/docs/parameters-schema-as-type-object.md) | **API, SDK.** Non-model, array, and record request bodies cannot evolve with additional properties without a breaking change. | | [`use-operation-decorator`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-operation-decorator/) | — | **API, SDK, Emitters.** Missing or mismatched decorators prevent resource operations from being associated with the correct resource, which can break SDK generation and resource-aware tooling. | | [`version-progression`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/version-progression/) | — | **API, SDK, Tooling.** Out-of-order versions make specs unmaintainable, and versions with matching dates cause SDK problems. | diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md index 7a6fb14fde..7371c440d3 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md @@ -69,6 +69,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-resource-manager/resource-name`](../rules/resource-name.md) | Check the resource name. | | [`@azure-tools/typespec-azure-resource-manager/retry-after`](../rules/retry-after.md) | Check if retry-after header appears in response body. | | [`@azure-tools/typespec-azure-resource-manager/unsupported-type`](../rules/unsupported-type.md) | Check for unsupported ARM types. | +| [`@azure-tools/typespec-azure-resource-manager/use-model-request-body`](../rules/use-model-request-body.md) | Request bodies must use plain models. | | [`@azure-tools/typespec-azure-resource-manager/secret-prop`](../rules/secret-prop.md) | RPC-v1-13: Check that property with names indicating sensitive information(e.g. contains auth, password, token, secret, etc.) are marked with @secret decorator. | | [`@azure-tools/typespec-azure-resource-manager/no-empty-model`](../rules/no-empty-model.md) | ARM Properties with type:object that don't reference a model definition are not allowed. ARM doesn't allow generic type definitions as this leads to bad customer experience. | | [`@azure-tools/typespec-azure-resource-manager/no-reserved-resource-property`](../rules/no-reserved-resource-property.md) | Reserved property names (for example 'billingData') must not be present in a resource's property bag. The property name is matched case-insensitively. |