Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 48 additions & 54 deletions packages/typespec-lintdiff/src/rules/patch-body-parameters-schema.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import { resolveProviderNamespace } from "@azure-tools/typespec-azure-resource-manager";
import {
createRule,
getLifecycleVisibilityEnum,
getVisibilityForClass,
paramMessage,
type Model,
type ModelProperty,
type Operation,
type Program,
} from "@typespec/compiler";
import { resolveProviderNamespace } from "@azure-tools/typespec-azure-resource-manager";
import { getHttpOperation } from "@typespec/http";

export const patchBodyParametersSchemaRule = createRule({
name: "patch-body-parameters-schema",
description:
"ARM PATCH body properties must not be required and must not have defaults.",
description: "ARM PATCH body properties must not be required, have defaults, or be create-only.",
severity: "warning",
messages: {
required: paramMessage`Properties of a PATCH request body must not be required, property:${"propertyName"}.`,
default: paramMessage`Properties of a PATCH request body must not have default value, property:${"propertyName"}.`,
createOnly: paramMessage`Properties of a PATCH request body must not be x-ms-mutability: ["create"], property:${"propertyName"}.`,
},
create(context) {
return {
operation: (operation) => {
const namespace = operation.interface?.namespace ?? operation.namespace;
if (
resolveProviderNamespace(context.program, namespace) === undefined
) {
if (resolveProviderNamespace(context.program, namespace) === undefined) {
return;
}

Expand All @@ -37,7 +37,7 @@ export const patchBodyParametersSchemaRule = createRule({
return;
}

for (const violation of findViolations(operation, patchBody)) {
for (const violation of findViolations(context.program, patchBody)) {
context.reportDiagnostic({
target: violation.target,
messageId: violation.messageId,
Expand All @@ -54,18 +54,18 @@ export const patchBodyParametersSchemaRule = createRule({
type Violation = {
target: ModelProperty;
propertyName: string;
messageId: "required" | "default";
messageId: "required" | "default" | "createOnly";
};

function findViolations(operation: Operation, patchModel: Model): Violation[] {
function findViolations(program: Program, patchModel: Model): Violation[] {
const violations: Violation[] = [];
collectViolations(patchModel, operation.name, violations, [], new Set());
collectViolations(program, patchModel, violations, [], new Set());
return violations;
}

function collectViolations(
program: Program,
model: Model,
resourceName: string,
violations: Violation[],
path: string[] = [],
visited: Set<Model> = new Set(),
Expand All @@ -77,65 +77,59 @@ function collectViolations(

for (const property of getModelProperties(model)) {
const propertyPath = [...path, property.name];
if (
!isTopLevelManagedIdentityException(resourceName, propertyPath, property)
) {
if (!property.optional) {
violations.push({
target: property,
propertyName: propertyPath.join("."),
messageId: "required",
});
}
if (isTopLevelIdentityProperty(propertyPath)) {
continue;
}

if (property.defaultValue !== undefined) {
violations.push({
target: property,
propertyName: propertyPath.join("."),
messageId: "default",
});
}
if (!property.optional) {
violations.push({
target: property,
propertyName: propertyPath.join("."),
messageId: "required",
});
}

if (property.defaultValue !== undefined) {
violations.push({
target: property,
propertyName: propertyPath.join("."),
messageId: "default",
});
}

if (isCreateOnlyMutability(program, property)) {
violations.push({
target: property,
propertyName: propertyPath.join("."),
messageId: "createOnly",
});
}

if (property.type.kind === "Model") {
collectViolations(
property.type,
resourceName,
violations,
propertyPath,
visited,
);
collectViolations(program, property.type, violations, propertyPath, visited);
}
}
}

function isTopLevelManagedIdentityException(
resourceName: string,
propertyPath: string[],
property: ModelProperty,
): boolean {
if (propertyPath.length !== 1 || property.name !== "identity") {
return false;
}
function isTopLevelIdentityProperty(propertyPath: string[]): boolean {
return propertyPath.length === 1 && propertyPath[0].toLowerCase() === "identity";
}

if (property.type.kind !== "Model") {
function isCreateOnlyMutability(program: Program, property: ModelProperty): boolean {
const lifecycle = getLifecycleVisibilityEnum(program);
const create = lifecycle.members.get("Create");
if (create === undefined) {
return false;
}

return (
property.type.name.includes("ManagedServiceIdentity") ||
property.type.name.includes("SystemAssignedServiceIdentity")
);
const visibility = getVisibilityForClass(program, property, lifecycle);
return visibility.size === 1 && visibility.has(create);
}

function getModelProperties(model: Model): ModelProperty[] {
const properties = new Map<string, ModelProperty>();

for (
let current: Model | undefined = model;
current !== undefined;
current = current.baseModel
) {
for (let current: Model | undefined = model; current !== undefined; current = current.baseModel) {
for (const property of current.properties.values()) {
if (!properties.has(property.name)) {
properties.set(property.name, property);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"violation": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import "../../lib/imports.tsp";

using TypeSpec.Http;
using TypeSpec.Rest;
using TypeSpec.Versioning;
using Azure.ResourceManager;

@armProviderNamespace
@service(#{ title: "Test Service" })
@versioned(Versions)
@armCommonTypesVersion(CommonTypes.Versions.v5)
namespace Microsoft.TestService;

enum Versions {
@useDependency(Azure.ResourceManager.CommonTypes.Versions.v5)
v2024_01_01: "2024-01-01",
}

model Widget is TrackedResource<WidgetProperties> {
@key("widgetName")
@segment("widgets")
@doc("The name of the widget")
@path
@pattern("^[a-zA-Z0-9_-]+$")
name: string;
}

@doc("Widget resource properties.")
model WidgetProperties {
@doc("Description of the widget")
description?: string;

@doc("Resource provisioning state")
@visibility(Lifecycle.Read)
provisioningState?: ResourceProvisioningState;
}

@doc("Patch envelope for widget.")
model WidgetPatchBody {
@doc("Resource properties with a create-only field")
properties?: WidgetPatchProperties;
}

@doc("Patch properties with a create-only property.")
model WidgetPatchProperties {
@doc("Create-only property in patch body -- violates rule")
@visibility(Lifecycle.Create)
createOnly?: string;
}

interface Operations extends Azure.ResourceManager.Operations {}

@armResourceOperations
interface Widgets {
get is ArmResourceRead<Widget>;
createOrUpdate is ArmResourceCreateOrReplaceAsync<Widget>;
delete is ArmResourceDeleteWithoutOkAsync<Widget>;

@doc("Update widget")
@patch
@armResourceUpdate(Widget)
update(
...ResourceInstanceParameters<Widget>,
@doc("The request body") @body body: WidgetPatchBody,
): ArmResponse<Widget> | ErrorResponse;
}
Loading