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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-resource-manager"
- "@azure-tools/typespec-azure-rulesets"
---

Split `arm-resource-operation` lint rule: add `arm-resource-operation-missing-decorator` and `arm-resource-operation-missing-api-version` as separate rules. The original rule now only checks that operations are inside an interface declaration.
4 changes: 4 additions & 0 deletions packages/typespec-azure-resource-manager/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { armResourceInvalidVersionFormatRule } from "./rules/arm-resource-invali
import { armResourceKeyInvalidCharsRule } from "./rules/arm-resource-key-invalid-chars.js";
import { armResourceNamePatternRule } from "./rules/arm-resource-name-pattern.js";
import { armResourceOperationsRule } from "./rules/arm-resource-operation-response.js";
import { armResourceOperationMissingApiVersionRule } from "./rules/arm-resource-operation-missing-api-version.js";
import { armResourceOperationMissingDecoratorRule } from "./rules/arm-resource-operation-missing-decorator.js";
import { patchOperationsRule } from "./rules/arm-resource-patch.js";
import { armResourcePathInvalidCharsRule } from "./rules/arm-resource-path-invalid-chars.js";
import { armResourceProvisioningStateRule } from "./rules/arm-resource-provisioning-state-rule.js";
Expand Down Expand Up @@ -56,6 +58,8 @@ const rules = [
armResourceKeyInvalidCharsRule,
armResourceNamePatternRule,
armResourceOperationsRule,
armResourceOperationMissingApiVersionRule,
armResourceOperationMissingDecoratorRule,
armResourcePathInvalidCharsRule,
armResourceProvisioningStateRule,
versionProgressionRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
Model,
ModelProperty,
Operation,
Program,
createRule,
isTemplateInstance,
} from "@typespec/compiler";
import {
getNamespaceName,
getSourceModel,
isSourceOperationResourceManagerInternal,
isTemplatedInterfaceOperation,
} from "./utils.js";

export const armResourceOperationMissingApiVersionRule = createRule({
name: "arm-resource-operation-missing-api-version",
severity: "warning",
description:
"Validate ARM Resource operations include the api-version parameter referencing Azure.ResourceManager.CommonTypes.ApiVersionParameter.",
url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-operation-missing-api-version",
messages: {
default:
"All Resource operations must use an api-version parameter. Please include Azure.ResourceManager.ApiVersionParameter in the operation parameter list using the spread (...ApiVersionParameter) operator, or using one of the common resource parameter models.",
},
create(context) {
return {
operation: (operation: Operation) => {
if (
!isSourceOperationResourceManagerInternal(operation) &&
!isTemplateInstance(operation) &&
!isTemplatedInterfaceOperation(operation)
) {
const parameters: Model = operation.parameters;
if (
parameters === undefined ||
parameters === null ||
!hasApiParameter(context.program, parameters)
) {
context.reportDiagnostic({
target: operation,
});
}
}
},
};
},
});

function isApiParameter(program: Program, property: ModelProperty): boolean {
if (property.type.kind !== "Scalar") return false;
if (!property.sourceProperty) return false;
const sourceModel: Model | undefined = getSourceModel(property.sourceProperty);
if (sourceModel === undefined) return false;
return (
sourceModel.name === "ApiVersionParameter" &&
getNamespaceName(program, sourceModel) === "Azure.ResourceManager.CommonTypes"
);
}

function hasApiParameter(program: Program, model: Model): boolean {
if (model.properties === undefined || model.properties.size === 0) return false;
const apiVersionParams: ModelProperty[] = [...model.properties.values()].filter((i) =>
isApiParameter(program, i),
);
return apiVersionParams !== null && apiVersionParams.length === 1;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
Operation,
Program,
createRule,
isTemplateInstance,
paramMessage,
} from "@typespec/compiler";
import { HttpVerb, getOperationVerb } from "@typespec/http";
import { getSegment } from "@typespec/rest";
import { isSourceOperationResourceManagerInternal, isTemplatedInterfaceOperation } from "./utils.js";

export const armResourceOperationMissingDecoratorRule = createRule({
name: "arm-resource-operation-missing-decorator",
severity: "warning",
description: "Validate ARM Resource operations use the correct decorator for the HTTP verb.",
url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-operation-missing-decorator",
messages: {
default: paramMessage`Resource ${"verb"} operation must be decorated with ${"decorator"}.`,
},
create(context) {
return {
operation: (operation: Operation) => {
if (
!isSourceOperationResourceManagerInternal(operation) &&
!isTemplateInstance(operation)
) {
const verb = getOperationVerb(context.program, operation);
if (verb && isTemplatedInterfaceOperation(operation) === false) {
const requiredDecorators = resourceOperationDecorators[verb];
if (requiredDecorators?.length > 0) {
const decorator = operation.decorators.find(
(d) => requiredDecorators.indexOf(d.decorator.name) >= 0,
);
if (!decorator && !isArmProviderOperation(context.program, operation)) {
context.reportDiagnostic({
target: operation,
format: {
verb: verb.toUpperCase(),
decorator: requiredDecorators.map((d) => `@${d.substring(1)}`).join(" or "),
},
});
}
}
}
}
},
};
},
});

const resourceOperationDecorators: { [verb in HttpVerb]: string[] } = {
put: ["$armResourceCreateOrUpdate"],
get: ["$armResourceRead", "$armResourceList"],
patch: ["$armResourceUpdate"],
delete: ["$armResourceDelete"],
post: ["$armResourceAction", "$armResourceCollectionAction"],
head: [],
};

function isStaticSegment(segment: string | undefined): boolean {
return segment === undefined || segment === "locations";
}

function isArmProviderOperation(program: Program, operation: Operation): boolean {
let isProviderAction = false;

for (const [index, [, property]] of [...operation.parameters.properties].entries()) {
const segment = getSegment(program, property);

if (segment === "providers") {
isProviderAction = true;
} else if (isProviderAction && !isStaticSegment(segment)) {
return false;
}

const isLastSegment = index === operation.parameters.properties.size - 1;
if (isLastSegment && !segment) {
return true;
}
}

return false;
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,14 @@
import {
Model,
ModelProperty,
Operation,
Program,
createRule,
isTemplateInstance,
paramMessage,
} from "@typespec/compiler";
import { Operation, createRule, isTemplateInstance } from "@typespec/compiler";
import { SyntaxKind } from "@typespec/compiler/ast";
import { HttpVerb, getOperationVerb } from "@typespec/http";
import { getSegment } from "@typespec/rest";
import {
getNamespaceName,
getSourceModel,
isSourceOperationResourceManagerInternal,
isTemplatedInterfaceOperation,
} from "./utils.js";
import { isSourceOperationResourceManagerInternal, isTemplatedInterfaceOperation } from "./utils.js";

export const coreOperationsRule = createRule({
name: "arm-resource-operation",
severity: "warning",
description: "Validate ARM Resource operations.",
description: "Validate ARM Resource operations are defined inside an interface declaration.",
url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-operation",
messages: {
default:
"All Resource operations must use an api-version parameter. Please include Azure.ResourceManager.ApiVersionParameter in the operation parameter list using the spread (...ApiVersionParameter) operator, or using one of the common resource parameter models.",
opOutsideInterface: "All operations must be inside an interface declaration.",
opMissingDecorator: paramMessage`Resource ${"verb"} operation must be decorated with ${"decorator"}.`,
default: "All operations must be inside an interface declaration.",
},
create(context) {
return {
Expand All @@ -35,100 +17,17 @@ export const coreOperationsRule = createRule({
!isSourceOperationResourceManagerInternal(operation) &&
!isTemplateInstance(operation)
) {
const verb = getOperationVerb(context.program, operation);
if (
!isTemplatedInterfaceOperation(operation) &&
(!operation.node?.parent ||
operation.node.parent.kind !== SyntaxKind.InterfaceStatement)
) {
context.reportDiagnostic({
messageId: "opOutsideInterface",
target: operation,
});
}
const parameters: Model = operation.parameters;
if (
parameters === undefined ||
parameters === null ||
!hasApiParameter(context.program, parameters)
) {
context.reportDiagnostic({
target: operation,
});
}
if (verb) {
const requiredDecorators = resourceOperationDecorators[verb];
if (requiredDecorators?.length > 0) {
const decorator = operation.decorators.find(
(d) => requiredDecorators.indexOf(d.decorator.name) >= 0,
);
if (!decorator && !isArmProviderOperation(context.program, operation)) {
context.reportDiagnostic({
messageId: "opMissingDecorator",
target: operation,
format: {
verb: verb.toUpperCase(),
decorator: requiredDecorators.map((d) => `@${d.substring(1)}`).join(" or "),
},
});
}
}
}
}
},
};
},
});

const resourceOperationDecorators: { [verb in HttpVerb]: string[] } = {
put: ["$armResourceCreateOrUpdate"],
get: ["$armResourceRead", "$armResourceList"],
patch: ["$armResourceUpdate"],
delete: ["$armResourceDelete"],
post: ["$armResourceAction", "$armResourceCollectionAction"],
head: [],
};

function isApiParameter(program: Program, property: ModelProperty): boolean {
if (property.type.kind !== "Scalar") return false;
if (!property.sourceProperty) return false;
const sourceModel: Model | undefined = getSourceModel(property.sourceProperty);
if (sourceModel === undefined) return false;
return (
sourceModel.name === "ApiVersionParameter" &&
getNamespaceName(program, sourceModel) === "Azure.ResourceManager.CommonTypes"
);
}

function hasApiParameter(program: Program, model: Model): boolean {
if (model.properties === undefined || model.properties.size === 0) return false;
const apiVersionParams: ModelProperty[] = [...model.properties.values()].filter((i) =>
isApiParameter(program, i),
);
return apiVersionParams !== null && apiVersionParams.length === 1;
}

function isStaticSegment(segment: string | undefined, property: ModelProperty): boolean {
return segment === undefined || segment === "locations";
}

function isArmProviderOperation(program: Program, operation: Operation): boolean {
let isProviderAction = false;

for (const [index, [, property]] of [...operation.parameters.properties].entries()) {
const segment = getSegment(program, property);

if (segment === "providers") {
isProviderAction = true;
} else if (isProviderAction && !isStaticSegment(segment, property)) {
return false;
}

const isLastSegment = index === operation.parameters.properties.size - 1;
if (isLastSegment && !segment) {
return true;
}
}

return false;
}
Loading