Skip to content
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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did this just reorder them?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the change is correct. It swaps two URIs that were assigned to each other's scenarios (Routes_fixed↔Routes_InInterface), confirmed against the Expected path docs in main.tsp. Not a cosmetic reorder.

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the query string are inconsitent? I think because typespec routes are uri template, the query is be part of uri and the mockapi, should we check for that?

@msyyc msyyc Jun 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — the query is part of the uri template, and right now the check strips it, so a constant param like fixed=true would slip through. I scoped this PR to path-level mismatches (where our silent 404s come from); query validation is trickier (order-independent, explode, reserved expansions) and lower value since only constant params can diverge.

Above all, I'd prefer to track it as a follow-up rather than expand scope here if you want.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine by me

"/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