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..78827e0dbb4 --- /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 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/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..b0ded1d0952 100644 --- a/packages/spector/src/actions/validate-mock-apis.ts +++ b/packages/spector/src/actions/validate-mock-apis.ts @@ -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; @@ -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}"`); @@ -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(); + 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(", ")}.`, + }); + } + } + } } } 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..a2d480ed896 --- /dev/null +++ b/packages/spector/src/utils/route-utils.test.ts @@ -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); + }); + + 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¶m=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); + }); + }); +}); diff --git a/packages/spector/src/utils/route-utils.ts b/packages/spector/src/utils/route-utils.ts new file mode 100644 index 00000000000..c91b5752e42 --- /dev/null +++ b/packages/spector/src/utils/route-utils.ts @@ -0,0 +1,158 @@ +/** + * Utilities to validate that the `uri` declared in a `mockapi.ts` mock API definition is + * 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 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 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"); +} + +/** + * 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; +} + +/** 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; +} + +/** + * 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 getServerPathPrefixSegmentCount(serverUrl: string | undefined): number { + if (!serverUrl) { + return 0; + } + 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 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); +}