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
138 changes: 36 additions & 102 deletions packages/typespec-lintdiff/src/rules/lro-error-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,37 @@ import {
isArmCommonType,
} from "@azure-tools/typespec-azure-resource-manager";
import {
createTCGCContext,
getMarkAsLro,
isInScope,
} from "@azure-tools/typespec-client-generator-core";
import {
compilerAssert,
createRule,
getService,
isNullType,
listServices,
type Operation,
type Service,
type Type,
} from "@typespec/compiler";
import { unsafe_mutateSubgraphWithNamespace } from "@typespec/compiler/experimental";
import {
createMetadataInfo,
getHttpService,
Visibility,
type HttpStatusCodeRange,
} from "@typespec/http";
import { getExtensions, shouldInline } from "@typespec/openapi";
import { getVersioningMutators } from "@typespec/versioning";

const standardErrorReference =
/.*\/common-types\/resource-management\/v(([1-9]\d+)|[2-9])\/types.json#\/definitions\/ErrorResponse/;

export const lroErrorContentRule = createRule({
name: "lro-error-content",
description:
"LRO error response references must use the ARM common-types v2 or later ErrorResponse.",
description: "Native ARM LRO error payloads must use the common-types v2 or later ErrorResponse.",
severity: "warning",
messages: {
default:
"Error response references of long running operations must use the common-types v2 or later ErrorResponse. Use `Azure.ResourceManager.CommonTypes.ErrorResponse` instead of a custom error reference.",
"Error payloads of long running operations must use the common-types v2 or later ErrorResponse. Use `Azure.ResourceManager.CommonTypes.ErrorResponse` instead of a custom error payload.",
},
create(context) {
const program = context.program;
const metadata = createMetadataInfo(program, { canonicalVisibility: Visibility.Read });
const tcgc = createTCGCContext(program, "@azure-tools/typespec-autorest", {
mutateNamespace: false,
});
const reported = new Set<Operation["node"]>();
const reported = new Set<Operation | Operation["node"]>();

function hasInvalidReference(type: Type, service: Service, version?: string): boolean {
function isStandardError(type: Type, service: Service): boolean {
let reference = getExternalTypeRef(program, type);
if (
!reference &&
Expand All @@ -60,110 +46,58 @@ export const lroErrorContentRule = createRule({
type.kind === "Enum" ||
type.kind === "Union")
) {
reference = getArmCommonTypeOpenAPIRef(program, type, { service, version });
reference = getArmCommonTypeOpenAPIRef(program, type, { service });
}
if (reference) {
// ARM's default directory is interpolated by AutoRest before emitting the reference.
return !standardErrorReference.test(
return standardErrorReference.test(
reference.replaceAll("{arm-types-dir}", "/common-types/resource-management"),
);
}
if (
(type.kind === "Scalar" && program.checker.isStdType(type)) ||
type.kind === "String" ||
type.kind === "StringTemplate" ||
type.kind === "Number" ||
type.kind === "Boolean" ||
(type.kind === "Intrinsic" && type.name === "unknown")
) {
return false;
}
type = metadata.getEffectivePayloadType(type, Visibility.Read);
if (!shouldInline(program, type)) {
return true;
}
// Inline nullable/single-member unions can still emit their member's top-level $ref.
// A nullable standard error still describes the standard error payload.
if (type.kind === "Union") {
const members = [...type.variants.values()]
.map((variant) => variant.type)
.filter((member) => !isNullType(member));
return members.length === 1 && hasInvalidReference(members[0], service, version);
return members.length === 1 && isStandardError(members[0], service);
}
return false;
}

function checkService(service: Service, version?: string) {
const [httpService] = getHttpService(program, service.type);
for (const httpOperation of httpService.operations) {
const operation = httpOperation.operation;
if (!isInScope(tcgc, operation)) {
continue;
}
const extensions = getExtensions(program, operation);
const isLro = extensions.has("x-ms-long-running-operation")
? extensions.get("x-ms-long-running-operation") === true
: (httpOperation.verb !== "get" && getLroMetadata(program, operation) !== undefined) ||
getMarkAsLro(tcgc, operation);
if (!isLro || reported.has(operation.node)) {
continue;
}
for (const response of httpOperation.responses) {
if (!isErrorResponse(response.statusCodes)) {
continue;
}
// AutoRest selects the last body; different bodies for one status are an emitter error.
const bodies = response.responses.flatMap((r) => (r.body ? [r.body] : []));
const body = bodies.at(-1);
if (
!body ||
body.bodyKind !== "single" ||
(body.type.kind === "Scalar" &&
body.type.name === "bytes" &&
bodies
.flatMap((b) => b.contentTypes)
.every(
(contentType) =>
contentType !== "application/json" && contentType !== "text/plain",
))
) {
continue;
}
if (hasInvalidReference(body.type, service, version)) {
context.reportDiagnostic({ target: operation });
reported.add(operation.node);
break;
}
}
}
}

return {
root() {
for (const service of listServices(program)) {
// Lintdiff runs mixed ARM/data-plane rules; descendants are included by getHttpService.
// Lintdiff runs mixed ARM/data-plane rules; remove this isolation on ARM promotion.
if (!getArmProviderNamespace(program, service.type)) {
continue;
}
const versioning = getVersioningMutators(program, service.type);
if (versioning === undefined) {
checkService(service);
continue;
}
const snapshots =
versioning.kind === "versioned"
? versioning.snapshots
: [{ mutator: versioning.mutator, version: undefined }];
for (const snapshot of snapshots) {
const projected = unsafe_mutateSubgraphWithNamespace(
program,
[snapshot.mutator],
service.type,
);
compilerAssert(projected.type.kind === "Namespace", "Expected a service namespace.");
checkService(
getService(program, projected.type) ?? { type: projected.type },
snapshot.version?.value,
const [httpService] = getHttpService(program, service.type);
for (const httpOperation of httpService.operations) {
const operation = httpOperation.operation;
const source = operation.node ?? operation;
if (
reported.has(source) ||
httpOperation.verb === "get" ||
getLroMetadata(program, operation) === undefined
) {
continue;
}
const invalidBody = httpOperation.responses.some(
(response) =>
isErrorResponse(response.statusCodes) &&
response.responses.some(
({ body }) =>
body !== undefined &&
(body.bodyKind !== "single" ||
!isStandardError(
metadata.getEffectivePayloadType(body.type, Visibility.Read),
service,
)),
),
);
if (invalidBody) {
context.reportDiagnostic({ target: operation });
reported.add(source);
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ model Failure<T> {
@statusCode statusCode: 400;
@body body: T;
}
@extension("x-ms-long-running-operation", true)
@route("/status")
op poll is Azure.Core.Foundations.GetOperationStatus;
model Accepted {
@statusCode statusCode: 202;
@header("Operation-Location") operationLocation: string;
}
@Azure.Core.pollingOperation(poll)
@post
op Lro<T>(@path name: string): AcceptedResponse | Failure<T>;
op Lro<T>(@path name: string): Accepted | Failure<T>;
@route("/old/{name}")
op old is Lro<OldError>;
@route("/standard/{name}")
Expand Down
Loading