Skip to content
Open
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,7 @@
---
changeKind: fix
packages:
- "@typespec/http-specs"
---

Fix the swapped mock api uris for the `Routes_fixed` and `Routes_InInterface` scenarios so they match the routes defined in the spec.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/spector"
---

`validate-mock-apis` now verifies that every route defined in a scenario's `main.tsp` is served by at least one of the scenario's mock API `uri`s, so a mismatch between the spec route and the mock api uri (which would make a generated client get a 404 from the mock server) is detected by CI.
4 changes: 2 additions & 2 deletions packages/http-specs/specs/routes/mockapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ function createTests(uri: string) {
});
}

Scenarios.Routes_InInterface = createTests("/routes/fixed");
Scenarios.Routes_fixed = createTests("/routes/in-interface/fixed");
Scenarios.Routes_InInterface = createTests("/routes/in-interface/fixed");
Scenarios.Routes_fixed = createTests("/routes/fixed");
Scenarios.Routes_PathParameters_templateOnly = createTests("/routes/path/template-only/a");
Scenarios.Routes_PathParameters_explicit = createTests("/routes/path/explicit/a");
Scenarios.Routes_PathParameters_annotationOnly = createTests("/routes/path/annotation-only/a");
Expand Down
85 changes: 82 additions & 3 deletions packages/spector/src/actions/validate-mock-apis.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import type { Operation } from "@typespec/compiler";
import pc from "picocolors";
import { logger } from "../logger.js";
import { findScenarioSpecFiles, loadScenarioMockApiFiles } from "../scenarios-resolver.js";
import { importSpecExpect, importTypeSpec } from "../spec-utils/import-spec.js";
import { importSpecExpect, importTypeSpec, importTypeSpecHttp } from "../spec-utils/import-spec.js";
import { createDiagnosticReporter } from "../utils/diagnostic-reporter.js";
import {
getServerPathPrefixSegmentCount,
isMockApiUriConsistentWithRoute,
normalizeMockApiUri,
} from "../utils/route-utils.js";

interface OperationRouteInfo {
routePath: string;
serverPrefixSegmentCounts: number[];
}

export interface ValidateMockApisConfig {
scenariosPath: string;
Expand All @@ -20,6 +31,7 @@ export async function validateMockApis({

const specCompiler = await importTypeSpec(scenariosPath);
const specExpect = await importSpecExpect(scenariosPath);
const httpLib = await importTypeSpecHttp(scenariosPath);
const diagnostics = createDiagnosticReporter();
for (const { name, specFilePath } of scenarioFiles) {
logger.debug(`Found scenario "${specFilePath}"`);
Expand Down Expand Up @@ -60,13 +72,80 @@ export async function validateMockApis({

const scenarios = specExpect.listScenarios(program);

// Resolve the real HTTP route of every operation from the spec. Unlike the route summary
// attached to each scenario endpoint, these routes are fully resolved (e.g. ARM routes,
// `@path` parameters and api-version path segments are included), so they can be reliably
// compared against the mock api uris.
const routeInfoByOperation = new Map<Operation, OperationRouteInfo[]>();
const [httpServices] = httpLib.getAllHttpServices(program);
for (const service of httpServices) {
const servers = httpLib.getServers(program, service.namespace);
// A service may declare several `@server`s with different path prefix lengths. Keep every
// distinct prefix length so a mock uri can be matched against any of them rather than forcing
// a single (e.g. shortest) prefix, which could otherwise produce false mismatches.
const serverPrefixSegmentCounts =
servers && servers.length > 0
? [...new Set(servers.map((server) => getServerPathPrefixSegmentCount(server.url)))]
: [0];
for (const httpOperation of service.operations) {
const infos = routeInfoByOperation.get(httpOperation.operation) ?? [];
infos.push({ routePath: httpOperation.path, serverPrefixSegmentCounts });
routeInfoByOperation.set(httpOperation.operation, infos);
}
}

let foundFailure = false;
for (const scenario of scenarios) {
if (mockApiFile.scenarios[scenario.name] === undefined) {
const mockApiScenario = mockApiFile.scenarios[scenario.name];
if (mockApiScenario === undefined) {
foundFailure = true;
diagnostics.reportDiagnostic({
message: `Scenario ${scenario.name} is missing implementation in for ${name} scenario file.`,
message: `Scenario ${scenario.name} is missing an implementation in the ${name} scenario file.`,
});
continue;
}

// Ensure every route defined in the spec is served by at least one mock api `uri`. Otherwise
// a generated client (which calls the spec route) would get a 404 from the mock server.
//
// The check is done per spec route (rather than per mock api uri) on purpose: a scenario may
// legitimately register extra mock handlers that are not declared operations in the spec
// (e.g. long-running-operation status-polling urls or server-driven pagination continuation
// pages), and those should not be flagged.
if (scenario.endpoints.length > 0 && Array.isArray(mockApiScenario.apis)) {
const mockUris = mockApiScenario.apis
.filter((api) => api.kind === "MockApiDefinition")
.map((api) => api.uri);

if (mockUris.length > 0) {
for (const endpoint of scenario.endpoints) {
const routeInfos = routeInfoByOperation.get(endpoint.target);
// Only validate when the route could be resolved. If it could not (e.g. an operation
// without an HTTP route), skip rather than risk a false positive.
if (!routeInfos || routeInfos.length === 0) {
continue;
}
const matched = routeInfos.some((info) =>
mockUris.some((uri) =>
info.serverPrefixSegmentCounts.some((serverPrefixSegmentCount) =>
isMockApiUriConsistentWithRoute(info.routePath, uri, serverPrefixSegmentCount),
),
),
);
if (!matched) {
foundFailure = true;
diagnostics.reportDiagnostic({
message: `Scenario ${scenario.name} defines the route ${routeInfos
.map((info) => `"${info.routePath}"`)
.join(
" or ",
)} but none of its mock api uris match it (route template params are treated as wildcards). Mock api uris: ${mockUris
.map((uri) => `"${normalizeMockApiUri(uri)}"`)
.join(", ")}.`,
});
}
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/spector/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./diagnostic-reporter.js";
export * from "./file-utils.js";
export * from "./misc-utils.js";
export * from "./request-utils.js";
export * from "./route-utils.js";
185 changes: 185 additions & 0 deletions packages/spector/src/utils/route-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import {
getServerPathPrefixSegmentCount,
isMockApiUriConsistentWithRoute,
normalizeMockApiUri,
} from "./route-utils.js";

describe("normalizeMockApiUri", () => {
it("drops the query string", () => {
expect(normalizeMockApiUri("/foo/bar?baz=1")).toBe("/foo/bar");
});

it("removes backslash escapes", () => {
expect(normalizeMockApiUri("/versioning/removed/api-version\\:v1/v3")).toBe(
"/versioning/removed/api-version:v1/v3",
);
});
});

describe("getServerPathPrefixSegmentCount", () => {
it("returns 0 for an undefined server", () => {
expect(getServerPathPrefixSegmentCount(undefined)).toBe(0);
});

it("returns 0 for the default localhost server", () => {
expect(getServerPathPrefixSegmentCount("http://localhost:3000")).toBe(0);
});

it("returns 0 for a bare host server (e.g. ARM)", () => {
expect(getServerPathPrefixSegmentCount("https://management.azure.com")).toBe(0);
});

it("counts the path segments after an {endpoint} template", () => {
expect(
getServerPathPrefixSegmentCount(
"{endpoint}/resiliency/service-driven/client:v2/service:{serviceDeploymentVersion}/api-version:{apiVersion}",
),
).toBe(5);
});

it("counts the path segments after localhost:3000", () => {
expect(getServerPathPrefixSegmentCount("http://localhost:3000/my/prefix")).toBe(2);
});
});

describe("isMockApiUriConsistentWithRoute", () => {
it("matches identical routes", () => {
expect(
isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic/simple"),
).toBe(true);
});

Comment thread
msyyc marked this conversation as resolved.
it("detects a mismatch in a literal segment", () => {
// A literal route segment must match exactly: `dollarSign` (camelCase) must not be considered
// consistent with a `dollar-sign` (kebab-case) uri.
expect(
isMockApiUriConsistentWithRoute(
"/parameters/query/special-char/dollarSign",
"/parameters/query/special-char/dollar-sign",
),
).toBe(false);
});

it("detects swapped routes", () => {
expect(isMockApiUriConsistentWithRoute("/routes/fixed", "/routes/in-interface/fixed")).toBe(
false,
);
});

it("treats whole-segment template expressions as wildcards", () => {
expect(
isMockApiUriConsistentWithRoute(
"/routes/path/template-only/{param}",
"/routes/path/template-only/a",
),
).toBe(true);
});

it("matches template expressions embedded in a segment", () => {
expect(
isMockApiUriConsistentWithRoute(
"/routes/path/simple/standard/primitive{param}",
"/routes/path/simple/standard/primitivea",
),
).toBe(true);
});

it("matches path expansion template expressions that expand to extra segments", () => {
expect(
isMockApiUriConsistentWithRoute(
"/routes/path/path/standard/primitive{param}",
"/routes/path/path/standard/primitive/a",
),
).toBe(true);
});

it("detects a uri that is missing a trailing literal segment present in the route", () => {
expect(isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic")).toBe(
false,
);
});

it("ignores trailing slashes", () => {
expect(
isMockApiUriConsistentWithRoute(
"/azure/special-headers/x-ms-client-request-id/",
"/azure/special-headers/x-ms-client-request-id",
),
).toBe(true);
});

it("ignores the query string of both the route and the uri", () => {
expect(
isMockApiUriConsistentWithRoute(
"/routes/query/query-continuation/standard/primitive?fixed=true",
"/routes/query/query-continuation/standard/primitive?fixed=true&param=a",
),
).toBe(true);
});

it("matches routes containing escaped characters in the uri", () => {
expect(
isMockApiUriConsistentWithRoute(
"/versioning/removed/api-version:{version}/v3",
"/versioning/removed/api-version\\:v1/v3",
),
).toBe(true);
});

describe("with a server path prefix", () => {
// Resiliency service-driven: the api-version/client/service version segments come from the
// `@server` url and may legitimately differ from the mock uri (e.g. client:v1 vs client:v2).
const serverPrefix = getServerPathPrefixSegmentCount(
"{endpoint}/resiliency/service-driven/client:v2/service:{serviceDeploymentVersion}/api-version:{apiVersion}",
);

it("skips the server prefix segments before comparing", () => {
expect(
isMockApiUriConsistentWithRoute(
"/add-optional-param/from-none",
"/resiliency/service-driven/client\\:v1/service\\:v1/api-version\\:v1/add-optional-param/from-none",
serverPrefix,
),
).toBe(true);
});

it("still detects a mismatch in the operation route after the server prefix", () => {
expect(
isMockApiUriConsistentWithRoute(
"/add-optional-param/from-none",
"/resiliency/service-driven/client\\:v2/service\\:v2/api-version\\:v2/add-operation",
serverPrefix,
),
).toBe(false);
});
});

describe("ARM routes", () => {
it("matches a fully resolved ARM tracked resource route", () => {
expect(
isMockApiUriConsistentWithRoute(
"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Azure.ResourceManager.Resources/topLevelTrackedResources/{topLevelTrackedResourceName}",
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Azure.ResourceManager.Resources/topLevelTrackedResources/top",
),
).toBe(true);
});

it("matches an extension resource route whose {resourceUri} spans several scope segments", () => {
const route =
"/{resourceUri}/providers/Azure.ResourceManager.Resources/extensionsResources/{extensionsResourceName}";
expect(
isMockApiUriConsistentWithRoute(
route,
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/test-rg/providers/Azure.ResourceManager.Resources/extensionsResources/extension",
),
).toBe(true);
expect(
isMockApiUriConsistentWithRoute(
route,
"/providers/Azure.ResourceManager.Resources/extensionsResources/extension",
),
).toBe(true);
});
});
});
Loading
Loading