From a5fa578fcc5b8f39ce2ddb4374312423d679c05e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:38:05 +0000 Subject: [PATCH 1/7] Initial plan From ad5841629aeabdfd7684b1ab6833255ef4987d7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:58:40 +0000 Subject: [PATCH 2/7] Detect mock api uri / spec route mismatch in validate-mock-apis Co-authored-by: msyyc <70930885+msyyc@users.noreply.github.com> --- ...ecs-fix-swapped-routes-2026-6-12-6-58-0.md | 7 ++ ...validate-mockapi-route-2026-6-12-6-58-0.md | 7 ++ packages/http-specs/specs/routes/mockapi.ts | 4 +- .../spector/src/actions/validate-mock-apis.ts | 28 +++++- packages/spector/src/utils/index.ts | 1 + .../spector/src/utils/route-utils.test.ts | 92 +++++++++++++++++++ packages/spector/src/utils/route-utils.ts | 91 ++++++++++++++++++ 7 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 .chronus/changes/http-specs-fix-swapped-routes-2026-6-12-6-58-0.md create mode 100644 .chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md create mode 100644 packages/spector/src/utils/route-utils.test.ts create mode 100644 packages/spector/src/utils/route-utils.ts diff --git a/.chronus/changes/http-specs-fix-swapped-routes-2026-6-12-6-58-0.md b/.chronus/changes/http-specs-fix-swapped-routes-2026-6-12-6-58-0.md new file mode 100644 index 00000000000..0e0670379db --- /dev/null +++ b/.chronus/changes/http-specs-fix-swapped-routes-2026-6-12-6-58-0.md @@ -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. diff --git a/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md b/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md new file mode 100644 index 00000000000..a568a24bc7d --- /dev/null +++ b/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/spector" +--- + +`validate-mock-apis` now verifies that the `uri` of each mock API definition is consistent with the route defined in the corresponding `main.tsp`, so a mismatch between the spec route and the mock api uri is detected by CI. diff --git a/packages/http-specs/specs/routes/mockapi.ts b/packages/http-specs/specs/routes/mockapi.ts index 8b1a0728a97..90bee3a583e 100644 --- a/packages/http-specs/specs/routes/mockapi.ts +++ b/packages/http-specs/specs/routes/mockapi.ts @@ -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"); diff --git a/packages/spector/src/actions/validate-mock-apis.ts b/packages/spector/src/actions/validate-mock-apis.ts index 73e07e565ef..916210d5aa7 100644 --- a/packages/spector/src/actions/validate-mock-apis.ts +++ b/packages/spector/src/actions/validate-mock-apis.ts @@ -3,6 +3,7 @@ import { logger } from "../logger.js"; import { findScenarioSpecFiles, loadScenarioMockApiFiles } from "../scenarios-resolver.js"; import { importSpecExpect, importTypeSpec } from "../spec-utils/import-spec.js"; import { createDiagnosticReporter } from "../utils/diagnostic-reporter.js"; +import { isMockApiUriConsistentWithRoute, normalizeMockApiUri } from "../utils/route-utils.js"; export interface ValidateMockApisConfig { scenariosPath: string; @@ -62,11 +63,36 @@ export async function validateMockApis({ 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.`, }); + continue; + } + + // Ensure the `uri` served by the mock api matches the route defined in the spec. Otherwise + // a generated client (which calls the spec route) would get a 404 from the mock server. + if (scenario.endpoints.length > 0 && Array.isArray(mockApiScenario.apis)) { + for (const api of mockApiScenario.apis) { + if (api.kind !== "MockApiDefinition") { + continue; + } + const matches = scenario.endpoints.some((endpoint) => + isMockApiUriConsistentWithRoute(endpoint.path, api.uri), + ); + if (!matches) { + foundFailure = true; + diagnostics.reportDiagnostic({ + message: `Scenario ${scenario.name} has a mock api uri "${normalizeMockApiUri( + api.uri, + )}" that does not match any of the routes defined in the spec: ${scenario.endpoints + .map((endpoint) => `"${endpoint.path}"`) + .join(", ")}.`, + }); + } + } } } diff --git a/packages/spector/src/utils/index.ts b/packages/spector/src/utils/index.ts index 2958ec7e24e..8b880125ff2 100644 --- a/packages/spector/src/utils/index.ts +++ b/packages/spector/src/utils/index.ts @@ -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"; diff --git a/packages/spector/src/utils/route-utils.test.ts b/packages/spector/src/utils/route-utils.test.ts new file mode 100644 index 00000000000..2a0e5e152f6 --- /dev/null +++ b/packages/spector/src/utils/route-utils.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { 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("isMockApiUriConsistentWithRoute", () => { + it("matches identical routes", () => { + expect( + isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic/simple"), + ).toBe(true); + }); + + it("detects a mismatch in a literal segment", () => { + // Regression test for the `$`-prefixed dollar-sign scenario mismatch. + expect( + isMockApiUriConsistentWithRoute( + "/parameters/query/special-char/dollarSign", + "/parameters/query/special-char/dollar-sign", + ), + ).toBe(false); + }); + + it("treats 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", () => { + // `{/param}` expands with a leading slash, so the route template segment contains a `/`. + expect( + isMockApiUriConsistentWithRoute( + "/routes/path/path/standard/record{/param}", + "/routes/path/path/standard/record/a,1,b,2", + ), + ).toBe(true); + expect( + isMockApiUriConsistentWithRoute( + "/routes/path/path/explode/array{/param*}", + "/routes/path/path/explode/array/a/b", + ), + ).toBe(true); + }); + + it("allows the uri to have extra segments not present in the route template", () => { + // `@path` annotated params, server-templated api-versions and reserved expansions are appended + // to the uri but are not part of the route template. + expect( + isMockApiUriConsistentWithRoute( + "/routes/path/annotation-only", + "/routes/path/annotation-only/a", + ), + ).toBe(true); + expect( + isMockApiUriConsistentWithRoute( + "/server/versions/versioned/with-path-api-version", + "/server/versions/versioned/with-path-api-version/2022-12-01-preview", + ), + ).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); + }); +}); diff --git a/packages/spector/src/utils/route-utils.ts b/packages/spector/src/utils/route-utils.ts new file mode 100644 index 00000000000..20fb5aa72bb --- /dev/null +++ b/packages/spector/src/utils/route-utils.ts @@ -0,0 +1,91 @@ +/** + * Utilities to validate that the `uri` declared in a `mockapi.ts` mock API definition is + * consistent with the route defined in the corresponding `main.tsp` spec. + * + * The mock server serves requests on the `uri` from `mockapi.ts`, while generated clients call + * the route defined in `main.tsp`. If they diverge the generated client gets a 404 at runtime, so + * we want CI to detect such a mismatch. + */ + +/** + * Normalize a mock API `uri` so it can be compared against a spec route. + * + * - Drops any query string (routes do not include the query in their path). + * - Removes backslash escapes (e.g. `\:` used to escape `:` for the express router). + */ +export function normalizeMockApiUri(uri: string): string { + return uri.split("?")[0].replace(/\\(.)/g, "$1"); +} + +function segmentToRegExp(segment: string): RegExp { + let pattern = ""; + let i = 0; + while (i < segment.length) { + if (segment[i] === "{") { + // URI template expression (e.g. `{param}`, `{+param}`, `{param*}`, `{/param}`). + // It can expand to anything, including reserved characters, so match greedily. + const end = segment.indexOf("}", i); + pattern += ".*"; + i = end === -1 ? segment.length : end + 1; + } else { + pattern += segment[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + i++; + } + } + return new RegExp(`^${pattern}$`); +} + +/** + * Split a route/uri into path segments on `/`, ignoring any `/` that appears inside a URI template + * expression (e.g. the `/` in `record{/param}`). + */ +function splitSegments(path: string): string[] { + const segments: string[] = []; + let current = ""; + let depth = 0; + for (const char of path) { + if (char === "{") { + depth++; + current += char; + } else if (char === "}") { + if (depth > 0) { + depth--; + } + current += char; + } else if (char === "/" && depth === 0) { + segments.push(current); + current = ""; + } else { + current += char; + } + } + segments.push(current); + return segments; +} + +/** + * Check whether a mock API `uri` is consistent with a spec route `template`. + * + * The comparison is done segment by segment. Segments containing a URI template expression + * (`{...}`) are treated as wildcards because the concrete value is unknown. Literal segments must + * match exactly. Path parameters that are not part of the route template (e.g. `@path` annotated + * params, server-templated api-versions, reserved expansions) are appended to the `uri` as extra + * segments, so the `uri` is allowed to have more segments than the route template. + */ +export function isMockApiUriConsistentWithRoute(template: string, uri: string): boolean { + const templateSegments = splitSegments(template.split("?")[0]); + const uriSegments = splitSegments(normalizeMockApiUri(uri)); + const length = Math.min(templateSegments.length, uriSegments.length); + for (let i = 0; i < length; i++) { + const templateSegment = templateSegments[i]; + const uriSegment = uriSegments[i]; + if (templateSegment.includes("{")) { + if (!segmentToRegExp(templateSegment).test(uriSegment)) { + return false; + } + } else if (templateSegment !== uriSegment) { + return false; + } + } + return true; +} From 4d84c27e637d88e1e0ad09a0d91da97ab4d86481 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:00:26 +0000 Subject: [PATCH 3/7] Address review: restrict wildcard to segment and clarify message Co-authored-by: msyyc <70930885+msyyc@users.noreply.github.com> --- packages/spector/src/actions/validate-mock-apis.ts | 2 +- packages/spector/src/utils/route-utils.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/spector/src/actions/validate-mock-apis.ts b/packages/spector/src/actions/validate-mock-apis.ts index 916210d5aa7..b9f125981bc 100644 --- a/packages/spector/src/actions/validate-mock-apis.ts +++ b/packages/spector/src/actions/validate-mock-apis.ts @@ -87,7 +87,7 @@ export async function validateMockApis({ diagnostics.reportDiagnostic({ message: `Scenario ${scenario.name} has a mock api uri "${normalizeMockApiUri( api.uri, - )}" that does not match any of the routes defined in the spec: ${scenario.endpoints + )}" that does not match (segment-by-segment, treating route template params as wildcards) any of the routes defined in the spec: ${scenario.endpoints .map((endpoint) => `"${endpoint.path}"`) .join(", ")}.`, }); diff --git a/packages/spector/src/utils/route-utils.ts b/packages/spector/src/utils/route-utils.ts index 20fb5aa72bb..d921d900764 100644 --- a/packages/spector/src/utils/route-utils.ts +++ b/packages/spector/src/utils/route-utils.ts @@ -23,9 +23,10 @@ function segmentToRegExp(segment: string): RegExp { while (i < segment.length) { if (segment[i] === "{") { // URI template expression (e.g. `{param}`, `{+param}`, `{param*}`, `{/param}`). - // It can expand to anything, including reserved characters, so match greedily. + // The concrete value is unknown, so match anything within the segment. Segments never + // contain a `/` (the path is already split on `/`), so restrict the wildcard accordingly. const end = segment.indexOf("}", i); - pattern += ".*"; + pattern += "[^/]*"; i = end === -1 ? segment.length : end + 1; } else { pattern += segment[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); From 8c115cbb0213dd420b16049f85af0bd206be1b33 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 12 Jun 2026 07:28:05 +0000 Subject: [PATCH 4/7] fix with comments --- packages/spector/src/actions/validate-mock-apis.ts | 2 +- packages/spector/src/utils/route-utils.test.ts | 12 ++++++++++++ packages/spector/src/utils/route-utils.ts | 10 ++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/spector/src/actions/validate-mock-apis.ts b/packages/spector/src/actions/validate-mock-apis.ts index b9f125981bc..9c8169849e9 100644 --- a/packages/spector/src/actions/validate-mock-apis.ts +++ b/packages/spector/src/actions/validate-mock-apis.ts @@ -67,7 +67,7 @@ export async function validateMockApis({ 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; } diff --git a/packages/spector/src/utils/route-utils.test.ts b/packages/spector/src/utils/route-utils.test.ts index 2a0e5e152f6..c56a96bcd71 100644 --- a/packages/spector/src/utils/route-utils.test.ts +++ b/packages/spector/src/utils/route-utils.test.ts @@ -81,6 +81,18 @@ describe("isMockApiUriConsistentWithRoute", () => { ).toBe(true); }); + it("detects a uri that is missing trailing segments present in the route template", () => { + expect( + isMockApiUriConsistentWithRoute( + "/routes/path/template-only/{param}", + "/routes/path/template-only", + ), + ).toBe(false); + expect( + isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic"), + ).toBe(false); + }); + it("matches routes containing escaped characters in the uri", () => { expect( isMockApiUriConsistentWithRoute( diff --git a/packages/spector/src/utils/route-utils.ts b/packages/spector/src/utils/route-utils.ts index d921d900764..e32985e735c 100644 --- a/packages/spector/src/utils/route-utils.ts +++ b/packages/spector/src/utils/route-utils.ts @@ -76,8 +76,14 @@ function splitSegments(path: string): string[] { export function isMockApiUriConsistentWithRoute(template: string, uri: string): boolean { const templateSegments = splitSegments(template.split("?")[0]); const uriSegments = splitSegments(normalizeMockApiUri(uri)); - const length = Math.min(templateSegments.length, uriSegments.length); - for (let i = 0; i < length; i++) { + // The uri may carry extra trailing segments (e.g. `@path` annotated params, server-templated + // api-versions, reserved expansions), but it must cover every segment of the route template. + // A uri that is only a prefix of the route serves fewer path segments than the client calls, so + // it is a real mismatch. + if (uriSegments.length < templateSegments.length) { + return false; + } + for (let i = 0; i < templateSegments.length; i++) { const templateSegment = templateSegments[i]; const uriSegment = uriSegments[i]; if (templateSegment.includes("{")) { From f00c745d2f2df5df101f731f59877932754609eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:36:19 +0000 Subject: [PATCH 5/7] fix: format route-utils.test.ts to pass Prettier check Co-authored-by: msyyc <70930885+msyyc@users.noreply.github.com> --- packages/spector/src/utils/route-utils.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spector/src/utils/route-utils.test.ts b/packages/spector/src/utils/route-utils.test.ts index c56a96bcd71..2b8b1bccca4 100644 --- a/packages/spector/src/utils/route-utils.test.ts +++ b/packages/spector/src/utils/route-utils.test.ts @@ -88,9 +88,9 @@ describe("isMockApiUriConsistentWithRoute", () => { "/routes/path/template-only", ), ).toBe(false); - expect( - isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic"), - ).toBe(false); + expect(isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic")).toBe( + false, + ); }); it("matches routes containing escaped characters in the uri", () => { From d0cfcf03462439fcec7b6bda01cdb1b8158c8986 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Mon, 15 Jun 2026 13:01:47 +0800 Subject: [PATCH 6/7] Fix validate-mock-apis route matching to eliminate false positives Resolve operation routes from getAllHttpServices (real HTTP routes) instead of the lossy scenario-endpoint route summary, and check per-spec-route that each route is served by at least one mock api uri (rather than per-mock-uri). This skips the server path prefix, supports ARM resource-uri scope spanning, and ignores extra supporting mock handlers (LRO status polling, pagination continuation pages) while still catching genuine route/mock mismatches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...validate-mockapi-route-2026-6-12-6-58-0.md | 2 +- .../spector/src/actions/validate-mock-apis.ts | 86 +++++++--- .../spector/src/utils/route-utils.test.ts | 140 ++++++++++++---- packages/spector/src/utils/route-utils.ts | 158 ++++++++++++------ 4 files changed, 287 insertions(+), 99 deletions(-) diff --git a/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md b/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md index a568a24bc7d..78827e0dbb4 100644 --- a/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md +++ b/.chronus/changes/spector-validate-mockapi-route-2026-6-12-6-58-0.md @@ -4,4 +4,4 @@ packages: - "@typespec/spector" --- -`validate-mock-apis` now verifies that the `uri` of each mock API definition is consistent with the route defined in the corresponding `main.tsp`, so a mismatch between the spec route and the mock api uri is detected by CI. +`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. diff --git a/packages/spector/src/actions/validate-mock-apis.ts b/packages/spector/src/actions/validate-mock-apis.ts index 9c8169849e9..c00e2a68a4f 100644 --- a/packages/spector/src/actions/validate-mock-apis.ts +++ b/packages/spector/src/actions/validate-mock-apis.ts @@ -1,9 +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 { isMockApiUriConsistentWithRoute, normalizeMockApiUri } from "../utils/route-utils.js"; +import { + getServerPathPrefixSegmentCount, + isMockApiUriConsistentWithRoute, + normalizeMockApiUri, +} from "../utils/route-utils.js"; + +interface OperationRouteInfo { + routePath: string; + serverPrefixSegmentCount: number; +} export interface ValidateMockApisConfig { scenariosPath: string; @@ -21,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}"`); @@ -61,6 +72,25 @@ 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(); + const [httpServices] = httpLib.getAllHttpServices(program); + for (const service of httpServices) { + const servers = httpLib.getServers(program, service.namespace); + const serverPrefixSegmentCount = + servers && servers.length > 0 + ? Math.min(...servers.map((server) => getServerPathPrefixSegmentCount(server.url))) + : 0; + for (const httpOperation of service.operations) { + const infos = routeInfoByOperation.get(httpOperation.operation) ?? []; + infos.push({ routePath: httpOperation.path, serverPrefixSegmentCount }); + routeInfoByOperation.set(httpOperation.operation, infos); + } + } + let foundFailure = false; for (const scenario of scenarios) { const mockApiScenario = mockApiFile.scenarios[scenario.name]; @@ -72,25 +102,43 @@ export async function validateMockApis({ continue; } - // Ensure the `uri` served by the mock api matches the route defined in the spec. Otherwise + // 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)) { - for (const api of mockApiScenario.apis) { - if (api.kind !== "MockApiDefinition") { - continue; - } - const matches = scenario.endpoints.some((endpoint) => - isMockApiUriConsistentWithRoute(endpoint.path, api.uri), - ); - if (!matches) { - foundFailure = true; - diagnostics.reportDiagnostic({ - message: `Scenario ${scenario.name} has a mock api uri "${normalizeMockApiUri( - api.uri, - )}" that does not match (segment-by-segment, treating route template params as wildcards) any of the routes defined in the spec: ${scenario.endpoints - .map((endpoint) => `"${endpoint.path}"`) - .join(", ")}.`, - }); + 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) => + isMockApiUriConsistentWithRoute(info.routePath, uri, info.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(", ")}.`, + }); + } } } } diff --git a/packages/spector/src/utils/route-utils.test.ts b/packages/spector/src/utils/route-utils.test.ts index 2b8b1bccca4..28cf80c67c6 100644 --- a/packages/spector/src/utils/route-utils.test.ts +++ b/packages/spector/src/utils/route-utils.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { isMockApiUriConsistentWithRoute, normalizeMockApiUri } from "./route-utils.js"; +import { + getServerPathPrefixSegmentCount, + isMockApiUriConsistentWithRoute, + normalizeMockApiUri, +} from "./route-utils.js"; describe("normalizeMockApiUri", () => { it("drops the query string", () => { @@ -13,6 +17,32 @@ describe("normalizeMockApiUri", () => { }); }); +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( @@ -21,7 +51,7 @@ describe("isMockApiUriConsistentWithRoute", () => { }); it("detects a mismatch in a literal segment", () => { - // Regression test for the `$`-prefixed dollar-sign scenario mismatch. + // Regression test for the dollar-sign scenario mismatch. expect( isMockApiUriConsistentWithRoute( "/parameters/query/special-char/dollarSign", @@ -30,7 +60,13 @@ describe("isMockApiUriConsistentWithRoute", () => { ).toBe(false); }); - it("treats template expressions as wildcards", () => { + 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}", @@ -49,48 +85,36 @@ describe("isMockApiUriConsistentWithRoute", () => { }); it("matches path expansion template expressions that expand to extra segments", () => { - // `{/param}` expands with a leading slash, so the route template segment contains a `/`. expect( isMockApiUriConsistentWithRoute( - "/routes/path/path/standard/record{/param}", - "/routes/path/path/standard/record/a,1,b,2", - ), - ).toBe(true); - expect( - isMockApiUriConsistentWithRoute( - "/routes/path/path/explode/array{/param*}", - "/routes/path/path/explode/array/a/b", + "/routes/path/path/standard/primitive{param}", + "/routes/path/path/standard/primitive/a", ), ).toBe(true); }); - it("allows the uri to have extra segments not present in the route template", () => { - // `@path` annotated params, server-templated api-versions and reserved expansions are appended - // to the uri but are not part of the route template. - expect( - isMockApiUriConsistentWithRoute( - "/routes/path/annotation-only", - "/routes/path/annotation-only/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( - "/server/versions/versioned/with-path-api-version", - "/server/versions/versioned/with-path-api-version/2022-12-01-preview", + "/azure/special-headers/x-ms-client-request-id/", + "/azure/special-headers/x-ms-client-request-id", ), ).toBe(true); }); - it("detects a uri that is missing trailing segments present in the route template", () => { + it("ignores the query string of both the route and the uri", () => { expect( isMockApiUriConsistentWithRoute( - "/routes/path/template-only/{param}", - "/routes/path/template-only", + "/routes/query/query-continuation/standard/primitive?fixed=true", + "/routes/query/query-continuation/standard/primitive?fixed=true¶m=a", ), - ).toBe(false); - expect(isMockApiUriConsistentWithRoute("/parameters/basic/simple", "/parameters/basic")).toBe( - false, - ); + ).toBe(true); }); it("matches routes containing escaped characters in the uri", () => { @@ -101,4 +125,60 @@ describe("isMockApiUriConsistentWithRoute", () => { ), ).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); + }); + }); }); diff --git a/packages/spector/src/utils/route-utils.ts b/packages/spector/src/utils/route-utils.ts index e32985e735c..c91b5752e42 100644 --- a/packages/spector/src/utils/route-utils.ts +++ b/packages/spector/src/utils/route-utils.ts @@ -1,41 +1,33 @@ /** * Utilities to validate that the `uri` declared in a `mockapi.ts` mock API definition is - * consistent with the route defined in the corresponding `main.tsp` spec. + * consistent with the route the generated client actually calls (the operation's HTTP route). * * The mock server serves requests on the `uri` from `mockapi.ts`, while generated clients call - * the route defined in `main.tsp`. If they diverge the generated client gets a 404 at runtime, so - * we want CI to detect such a mismatch. + * the route resolved from `main.tsp`. If they diverge the generated client gets a 404 at runtime, + * so we want CI to detect such a mismatch. + * + * The comparison is intentionally tolerant so it never produces false positives on legitimate + * specs: + * - The server endpoint portion of the `uri` is ignored. It is configured by the client at + * runtime and may legitimately differ from the spec's `@server` template (e.g. a multi-version + * service whose mock serves several `client:vN` endpoints). Only the operation route portion is + * compared. + * - Route template parameters (`{param}`) are treated as wildcards because the concrete value is + * unknown. A whole-segment parameter may also span multiple uri segments or be absent to account + * for reserved/path expansions (e.g. ARM `{resourceUri}` scopes, `{+param}`). + * - Trailing slashes and query strings are ignored. */ /** * Normalize a mock API `uri` so it can be compared against a spec route. * - * - Drops any query string (routes do not include the query in their path). + * - Drops any query string (routes are compared on their path only). * - Removes backslash escapes (e.g. `\:` used to escape `:` for the express router). */ export function normalizeMockApiUri(uri: string): string { return uri.split("?")[0].replace(/\\(.)/g, "$1"); } -function segmentToRegExp(segment: string): RegExp { - let pattern = ""; - let i = 0; - while (i < segment.length) { - if (segment[i] === "{") { - // URI template expression (e.g. `{param}`, `{+param}`, `{param*}`, `{/param}`). - // The concrete value is unknown, so match anything within the segment. Segments never - // contain a `/` (the path is already split on `/`), so restrict the wildcard accordingly. - const end = segment.indexOf("}", i); - pattern += "[^/]*"; - i = end === -1 ? segment.length : end + 1; - } else { - pattern += segment[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - i++; - } - } - return new RegExp(`^${pattern}$`); -} - /** * Split a route/uri into path segments on `/`, ignoring any `/` that appears inside a URI template * expression (e.g. the `/` in `record{/param}`). @@ -64,35 +56,103 @@ function splitSegments(path: string): string[] { return segments; } +/** Split into path segments dropping the leading (leading `/`) and trailing (trailing `/`) empties. */ +function toSegments(path: string): string[] { + const segments = splitSegments(path); + while (segments.length > 0 && segments[0] === "") { + segments.shift(); + } + while (segments.length > 0 && segments[segments.length - 1] === "") { + segments.pop(); + } + return segments; +} + /** - * Check whether a mock API `uri` is consistent with a spec route `template`. - * - * The comparison is done segment by segment. Segments containing a URI template expression - * (`{...}`) are treated as wildcards because the concrete value is unknown. Literal segments must - * match exactly. Path parameters that are not part of the route template (e.g. `@path` annotated - * params, server-templated api-versions, reserved expansions) are appended to the `uri` as extra - * segments, so the `uri` is allowed to have more segments than the route template. + * Number of path segments contributed by a service `@server` url. These segments are part of the + * endpoint the client configures, not the operation route, so they are skipped when comparing a + * mock uri against an operation route. */ -export function isMockApiUriConsistentWithRoute(template: string, uri: string): boolean { - const templateSegments = splitSegments(template.split("?")[0]); - const uriSegments = splitSegments(normalizeMockApiUri(uri)); - // The uri may carry extra trailing segments (e.g. `@path` annotated params, server-templated - // api-versions, reserved expansions), but it must cover every segment of the route template. - // A uri that is only a prefix of the route serves fewer path segments than the client calls, so - // it is a real mismatch. - if (uriSegments.length < templateSegments.length) { - return false; +export function getServerPathPrefixSegmentCount(serverUrl: string | undefined): number { + if (!serverUrl) { + return 0; } - for (let i = 0; i < templateSegments.length; i++) { - const templateSegment = templateSegments[i]; - const uriSegment = uriSegments[i]; - if (templateSegment.includes("{")) { - if (!segmentToRegExp(templateSegment).test(uriSegment)) { - return false; - } - } else if (templateSegment !== uriSegment) { - return false; + let path: string; + if (serverUrl.includes("{endpoint}")) { + path = serverUrl.split("{endpoint}")[1] ?? ""; + } else if (serverUrl.includes("localhost:3000")) { + path = serverUrl.split("localhost:3000")[1] ?? ""; + } else { + try { + path = new URL(serverUrl).pathname; + } catch { + path = ""; } } - return true; + return toSegments(path.split("?")[0]).length; +} + +function escapeLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Build a regex pattern for a single segment that may embed template params (e.g. `primitive{param}`). */ +function segmentPattern(segment: string): string { + let pattern = ""; + let i = 0; + while (i < segment.length) { + if (segment[i] === "{") { + const end = segment.indexOf("}", i); + // The parameter value is unknown. Allow it to span the rest of the segment (and, for + // path/reserved expansions, into following segments) without crossing a query string. + pattern += "[^?]*?"; + i = end === -1 ? segment.length : end + 1; + } else { + pattern += escapeLiteral(segment[i]); + i++; + } + } + return pattern; +} + +function buildRouteRegExp(routePath: string): RegExp { + const segments = toSegments(routePath.split("?")[0]); + let pattern = ""; + for (const segment of segments) { + if (/^\{[^}]*\}$/.test(segment)) { + // Whole-segment parameter: optional and may span several uri segments to account for + // reserved/path expansions (e.g. ARM `{resourceUri}` scopes, `{+param}`). + pattern += "(?:/[^?]+?)?"; + } else { + pattern += "/" + segmentPattern(segment); + } + } + if (pattern === "") { + pattern = "/?"; + } + // Allow the uri to carry extra trailing segments that are not part of the operation route (e.g. + // server-driven pagination continuation pages like `.../link/nextPage` that the mock serves but + // that are not declared operations in the spec). + return new RegExp(`^${pattern}(?:/[^?]+)*/?$`); +} + +/** + * Check whether a mock API `uri` is consistent with an operation's HTTP `routePath`. + * + * @param routePath The server-relative route resolved from the spec (e.g. from `getAllHttpServices`). + * @param uri The `uri` declared in the mock api. + * @param serverPrefixSegmentCount Number of leading uri segments contributed by the `@server` url, + * which are skipped before comparing (see {@link getServerPathPrefixSegmentCount}). + */ +export function isMockApiUriConsistentWithRoute( + routePath: string, + uri: string, + serverPrefixSegmentCount = 0, +): boolean { + const uriSegments = toSegments(normalizeMockApiUri(uri)); + if (uriSegments.length < serverPrefixSegmentCount) { + return false; + } + const remainder = "/" + uriSegments.slice(serverPrefixSegmentCount).join("/"); + return buildRouteRegExp(routePath).test(remainder); } From b152f41cde26cbd70e3982328f2dfa2c5f6e6a60 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Mon, 15 Jun 2026 13:20:49 +0800 Subject: [PATCH 7/7] Address review feedback: support multiple server prefixes; clarify test comment - Match a mock uri against every distinct @server path-prefix length (instead of only the shortest), so services declaring multiple servers with different prefix lengths cannot produce false mismatches. - Reword the literal-segment mismatch test comment so it stays accurate after the dollar-sign spec fix merged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spector/src/actions/validate-mock-apis.ts | 17 +++++++++++------ packages/spector/src/utils/route-utils.test.ts | 3 ++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/spector/src/actions/validate-mock-apis.ts b/packages/spector/src/actions/validate-mock-apis.ts index c00e2a68a4f..b0ded1d0952 100644 --- a/packages/spector/src/actions/validate-mock-apis.ts +++ b/packages/spector/src/actions/validate-mock-apis.ts @@ -12,7 +12,7 @@ import { interface OperationRouteInfo { routePath: string; - serverPrefixSegmentCount: number; + serverPrefixSegmentCounts: number[]; } export interface ValidateMockApisConfig { @@ -80,13 +80,16 @@ export async function validateMockApis({ const [httpServices] = httpLib.getAllHttpServices(program); for (const service of httpServices) { const servers = httpLib.getServers(program, service.namespace); - const serverPrefixSegmentCount = + // 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 - ? Math.min(...servers.map((server) => getServerPathPrefixSegmentCount(server.url))) - : 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, serverPrefixSegmentCount }); + infos.push({ routePath: httpOperation.path, serverPrefixSegmentCounts }); routeInfoByOperation.set(httpOperation.operation, infos); } } @@ -124,7 +127,9 @@ export async function validateMockApis({ } const matched = routeInfos.some((info) => mockUris.some((uri) => - isMockApiUriConsistentWithRoute(info.routePath, uri, info.serverPrefixSegmentCount), + info.serverPrefixSegmentCounts.some((serverPrefixSegmentCount) => + isMockApiUriConsistentWithRoute(info.routePath, uri, serverPrefixSegmentCount), + ), ), ); if (!matched) { diff --git a/packages/spector/src/utils/route-utils.test.ts b/packages/spector/src/utils/route-utils.test.ts index 28cf80c67c6..a2d480ed896 100644 --- a/packages/spector/src/utils/route-utils.test.ts +++ b/packages/spector/src/utils/route-utils.test.ts @@ -51,7 +51,8 @@ describe("isMockApiUriConsistentWithRoute", () => { }); it("detects a mismatch in a literal segment", () => { - // Regression test for the dollar-sign scenario mismatch. + // 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",