Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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,6 @@
- summary: |
Fix WireMock wire test generation for endpoints whose OpenAPI paths embed query
strings (e.g. Stainless overload dispatch pattern `?stainless_overload=methodName`).
Query parameters are now correctly stripped from WithPath and added as separate
WithParam calls, matching WireMock.Net's path-only matching behavior.
type: fix
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,18 @@ export class MockEndpointGenerator extends WithGeneration {
writer.newLine();

writer.write("Server.Given(WireMock.RequestBuilders.Request.Create()");
writer.write(`.WithPath("${this.toWireMockPath(example.url)}")`);
// Strip any embedded query string from the URL before passing to WithPath
const exampleUrl = this.stripQueryString(example.url);
writer.write(`.WithPath("${this.toWireMockPath(exampleUrl)}")`);

// Add query parameters embedded in the endpoint's full path
// (e.g. "/path?stainless_overload=multiQuery" → WithParam("stainless_overload", "multiQuery"))
const pathQueryParams = this.extractPathQueryParams(endpoint);
for (const [key, value] of Object.entries(pathQueryParams)) {
const escapedKey = this.escapeForCSharpStringLiteral(key);
const escapedValue = this.escapeForCSharpStringLiteral(value);
writer.write(`.WithParam("${escapedKey}", "${escapedValue}")`);
}

for (const parameter of example.queryParameters) {
const maybeParameterValue = this.exampleToQueryOrHeaderValue(parameter);
Expand Down Expand Up @@ -208,6 +219,39 @@ export class MockEndpointGenerator extends WithGeneration {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

private stripQueryString(url: string | undefined): string | undefined {
if (!url) {
return url;
}
const queryIndex = url.indexOf("?");
if (queryIndex !== -1) {
return url.substring(0, queryIndex);
}
return url;
}

private extractPathQueryParams(endpoint: HttpEndpoint): Record<string, string> {
let path = endpoint.fullPath.head;
for (const part of endpoint.fullPath.parts) {
path += `{${part.pathParameter}}${part.tail}`;
}
const queryIndex = path.indexOf("?");
if (queryIndex === -1) {
return {};
}
const queryString = path.substring(queryIndex + 1);
const params: Record<string, string> = {};
for (const pair of queryString.split("&")) {
const eqIndex = pair.indexOf("=");
if (eqIndex !== -1) {
const key = decodeURIComponent(pair.substring(0, eqIndex));
const value = decodeURIComponent(pair.substring(eqIndex + 1));
params[key] = value;
}
}
return params;
}

/*
If the example not a string, skip for now. If it's a string, check if it's a datetime
and normalize the string so that we can match it in wire tests.
Expand Down
61 changes: 54 additions & 7 deletions generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@ export class WireTestGenerator {
let out: Record<string, WireMockMapping> = {};
const wiremockStubMapping = WireTestSetupGenerator.getWiremockConfigContent(this.context.ir);
for (const mapping of wiremockStubMapping.mappings) {
// Use the original full path (including query string) from metadata when available,
// so that endpoints sharing the same base path but differing by query-string overload
// (e.g. Stainless's ?stainless_overload=...) each get a unique key.
const pathForKey = mapping.metadata.fullPathTemplate ?? mapping.request.urlPathTemplate;
const key = this.wiremockMappingKey({
requestMethod: mapping.request.method,
requestUrlPathTemplate: mapping.request.urlPathTemplate
requestUrlPathTemplate: pathForKey
});
out[key] = mapping;
}
Expand Down Expand Up @@ -808,19 +812,29 @@ export class WireTestGenerator {
}

private buildBasePath(endpoint: FernIr.HttpEndpoint): string {
let basePath =
// Build the full path including any embedded query string for key lookup
let fullPath =
endpoint.fullPath.head +
endpoint.fullPath.parts.map((part) => `{${part.pathParameter}}${part.tail}`).join("");

if (!basePath.startsWith("/")) {
basePath = `/${basePath}`;
if (!fullPath.startsWith("/")) {
fullPath = `/${fullPath}`;
}

// Use the full path (with query string) for the mapping key lookup,
// since getWireMockConfigContent keys by fullPathTemplate when available.
const mappingKey = this.wiremockMappingKey({
requestMethod: endpoint.method,
requestUrlPathTemplate: basePath
requestUrlPathTemplate: fullPath
Comment on lines +816 to +828

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.

🚩 Fragment handling inconsistency between buildRawFullPath and Go v2 buildBasePath

The buildRawFullPath method in packages/commons/mock-utils/src/index.ts:446-449 strips URL fragments (e.g., #refresh) from paths, but the Go v2 buildBasePath at generators/go-v2/sdk/src/wire-tests/WireTestGenerator.ts:816-818 does NOT strip fragments when constructing the lookup key. For endpoints with fragments (like /oauth2/token#refresh), fullPathTemplate would be undefined (since buildRawFullPath produces the same as urlPathTemplate after fragment stripping), so the stored key would be "POST - /oauth2/token". But buildBasePath would produce "POST - /oauth2/token#refresh" as the lookup key. This is a pre-existing mismatch (it existed before this PR in the old code where fragments weren't stripped from the lookup key either), but it's worth noting that this PR doesn't fix it. If fragment-path endpoints exist in Go API specs, they'd still cause a fatal error at the throw GeneratorError.internalError on line 841.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

});

// Strip query string from the path for the actual URL path used in VerifyRequestCount
let basePath = fullPath;
const queryIndex = basePath.indexOf("?");
if (queryIndex !== -1) {
basePath = basePath.substring(0, queryIndex);
}

const wiremockMapping = this.wireMockConfigContent[mappingKey];
// Take the first 15 keys
if (!wiremockMapping) {
Expand Down Expand Up @@ -864,7 +878,11 @@ export class WireTestGenerator {
private buildQueryParamsMap(endpoint: FernIr.HttpEndpoint): string {
const dynamicEndpointExample = this.getDynamicEndpointExample(endpoint);

if (!dynamicEndpointExample?.queryParameters) {
// Extract query parameters embedded in the endpoint's full path
// (e.g. "/path?stainless_overload=multiQuery" → { stainless_overload: "multiQuery" })
const pathQueryParams = this.extractPathQueryParams(endpoint);

if (!dynamicEndpointExample?.queryParameters && Object.keys(pathQueryParams).length === 0) {
return "nil";
}

Expand All @@ -881,7 +899,14 @@ export class WireTestGenerator {
}

const queryParamEntries: string[] = [];
for (const [paramName, paramValue] of Object.entries(dynamicEndpointExample.queryParameters)) {

// Add query parameters extracted from the endpoint's path first
for (const [paramName, paramValue] of Object.entries(pathQueryParams)) {
queryParamEntries.push(`${JSON.stringify(paramName)}: ${JSON.stringify(paramValue)}`);
}

// Add query parameters from the dynamic example
for (const [paramName, paramValue] of Object.entries(dynamicEndpointExample?.queryParameters ?? {})) {
if (paramValue != null) {
const key = JSON.stringify(paramName);
if (Array.isArray(paramValue) && paramValue.length > 1) {
Expand Down Expand Up @@ -910,6 +935,28 @@ export class WireTestGenerator {
return `map[string]interface{}{${queryParamEntries.join(", ")}}`;
}

private extractPathQueryParams(endpoint: FernIr.HttpEndpoint): Record<string, string> {
let path = endpoint.fullPath.head;
for (const part of endpoint.fullPath.parts) {
path += `{${part.pathParameter}}${part.tail}`;
}
const queryIndex = path.indexOf("?");
if (queryIndex === -1) {
return {};
}
const queryString = path.substring(queryIndex + 1);
const params: Record<string, string> = {};
for (const pair of queryString.split("&")) {
const eqIndex = pair.indexOf("=");
if (eqIndex !== -1) {
const key = decodeURIComponent(pair.substring(0, eqIndex));
const value = decodeURIComponent(pair.substring(eqIndex + 1));
params[key] = value;
}
}
return params;
}

private getDynamicEndpointExample(endpoint: FernIr.HttpEndpoint): FernIr.dynamic.EndpointExample | null {
const example = this.dynamicIr.endpoints[endpoint.id];
if (!example) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- summary: |
Fix WireMock wire test generation for endpoints whose OpenAPI paths embed query
strings (e.g. Stainless overload dispatch pattern `?stainless_overload=methodName`).
Query parameters are now correctly stripped from the URL path and passed separately
in VerifyRequestCount, matching WireMock's path-only matching behavior.
type: fix
68 changes: 65 additions & 3 deletions packages/commons/mock-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface WireMockMapping {
via: string;
};
};
/** Original path template including any embedded query string, for lookup disambiguation. */
fullPathTemplate?: string;
};
postServeActions?: unknown[];
}
Expand Down Expand Up @@ -168,8 +170,10 @@ export class WireMock {
example: FernIr.ExampleEndpointCall | undefined,
streamConditionProperty: string | undefined
): WireMockMapping | null {
// Build URL path template
// Build URL path template (stripped of query params for WireMock matching)
const urlPathTemplate = this.buildUrlPathTemplate(endpoint);
// Preserve the original full path (including any embedded query string) for lookup disambiguation
const rawFullPath = this.buildRawFullPath(endpoint);

// Extract path parameters from example (root, service, and endpoint levels)
const pathParameters: Record<string, { equalTo: string }> = {};
Expand All @@ -188,8 +192,11 @@ export class WireMock {
}
}

// Extract query parameters embedded in the path (e.g. ?stainless_overload=branchFrom)
const pathQueryParams = this.extractPathQueryParameters(endpoint);

// Extract query parameters from example
const queryParameters: Record<string, QueryParameterMatcher> = {};
const queryParameters: Record<string, QueryParameterMatcher> = { ...pathQueryParams };
for (const param of example?.queryParameters || []) {
const paramName = param.name != null ? getWireValue(param.name) : undefined;
if (!paramName) {
Expand Down Expand Up @@ -380,7 +387,8 @@ export class WireMock {
at: "2020-01-01T00:00:00.000Z",
via: "SYSTEM"
}
}
},
fullPathTemplate: rawFullPath !== urlPathTemplate ? rawFullPath : undefined
}
};

Expand Down Expand Up @@ -413,9 +421,63 @@ export class WireMock {
if (fragmentIndex !== -1) {
path = path.substring(0, fragmentIndex);
}
// Strip query string — query parameters embedded in the path (e.g. Stainless
// overload paths like "?stainless_overload=branchFrom") must be matched via
// the separate queryParameters field, not as part of urlPathTemplate.
const queryIndex = path.indexOf("?");
if (queryIndex !== -1) {
path = path.substring(0, queryIndex);
}
Comment on lines +424 to +430

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.

🔴 Shared buildUrlPathTemplate now strips query strings from urlPathTemplate, breaking key lookups in Python, Ruby, PHP, and Rust generators

The buildUrlPathTemplate method in mock-utils now strips query strings from the urlPathTemplate field of WireMockMapping. Only the Go v2 and C# generators were updated to use the new metadata.fullPathTemplate for disambiguation. The Python (generators/python-v2/sdk/src/wire-tests/WireTestGenerator.ts:769), Ruby (generators/ruby-v2/sdk/src/wire-tests/WireTestGenerator.ts:562), PHP (generators/php/sdk/src/wire-tests/WireTestGenerator.ts:431), and Rust (generators/rust/sdk/src/wire-tests/WireTestGenerator.ts:540) generators still key their wireMockConfigContent map by mapping.request.urlPathTemplate. When processing endpoints with embedded query strings (e.g., /path?stainless_overload=foo), the stored key is now "POST - /path" but their buildBasePath methods construct lookup keys with the query string intact ("POST - /path?stainless_overload=foo"), causing a mismatch. For Rust, this throws a fatal GeneratorError.internalError. For Ruby, it logs a warning and returns an unsubstituted path. For Python and PHP, the mapping lookup silently fails and path parameters are not substituted.

Affected consumer code patterns (all unchanged by this PR)

All four generators do:

// getWireMockConfigContent() - uses urlPathTemplate which now lacks query string
const key = this.wiremockMappingKey({ requestMethod: mapping.request.method, requestUrlPathTemplate: mapping.request.urlPathTemplate });

// buildBasePath() - uses raw endpoint.fullPath which still has query string
let basePath = endpoint.fullPath.head + parts;
const mappingKey = this.wiremockMappingKey({ requestMethod: endpoint.method, requestUrlPathTemplate: basePath });
Prompt for agents
The buildUrlPathTemplate method in mock-utils now strips query strings from urlPathTemplate, but only the Go v2 and C# generators were updated to handle this change. The Python v2, Ruby v2, PHP, and Rust generators still use mapping.request.urlPathTemplate as the key in getWireMockConfigContent() and construct lookup keys from endpoint.fullPath (which still includes query strings) in buildBasePath().

To fix this, each of the four affected generators needs two changes:
1. In getWireMockConfigContent(): Use mapping.metadata.fullPathTemplate ?? mapping.request.urlPathTemplate for the key (same as the Go v2 fix)
2. In buildBasePath(): Use the full path (including query string) for the mapping key lookup, then strip the query string from the actual base path used for URL construction

Affected files:
- generators/python-v2/sdk/src/wire-tests/WireTestGenerator.ts (getWireMockConfigContent at line 763, buildBasePath at line 779)
- generators/ruby-v2/sdk/src/wire-tests/WireTestGenerator.ts (getWireMockConfigContent at line 556, buildBasePath around line 390)
- generators/php/sdk/src/wire-tests/WireTestGenerator.ts (getWireMockConfigContent at line 427, buildBasePath at line 437)
- generators/rust/sdk/src/wire-tests/WireTestGenerator.ts (getWireMockConfigContent at line 534, buildBasePath at line 408)

See the Go v2 WireTestGenerator.ts lines 46-61 and 814-836 for the reference implementation of both fixes.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return path;
}

/**
* Builds the raw full path template including any embedded query string.
* Used only for metadata / lookup disambiguation, not for WireMock matching.
*/
private buildRawFullPath(endpoint: FernIr.HttpEndpoint): string {
let path = endpoint.fullPath.head;
for (const part of endpoint.fullPath.parts || []) {
path += `{${part.pathParameter}}${part.tail}`;
}
if (!path.startsWith("/")) {
path = "/" + path;
}
const fragmentIndex = path.indexOf("#");
if (fragmentIndex !== -1) {
path = path.substring(0, fragmentIndex);
}
return path;
}

/**
* Extracts query parameters that are embedded in the endpoint's full path.
* Some OpenAPI specs embed query strings directly in the path key
* (e.g. "/v2/namespaces/{namespace}?stainless_overload=branchFrom").
* These need to be extracted and matched as WireMock queryParameters.
*/
private extractPathQueryParameters(endpoint: FernIr.HttpEndpoint): Record<string, QueryParameterMatcher> {
let path = endpoint.fullPath.head;
for (const part of endpoint.fullPath.parts || []) {
path += `{${part.pathParameter}}${part.tail}`;
}
const queryIndex = path.indexOf("?");
if (queryIndex === -1) {
return {};
}
const queryString = path.substring(queryIndex + 1);
const params: Record<string, QueryParameterMatcher> = {};
for (const pair of queryString.split("&")) {
const eqIndex = pair.indexOf("=");
if (eqIndex !== -1) {
const key = decodeURIComponent(pair.substring(0, eqIndex));
const value = decodeURIComponent(pair.substring(eqIndex + 1));
params[key] = { equalTo: value };
}
}
return params;
}

public exampleToQueryOrHeaderValue({ value }: { value: FernIr.ExampleTypeReference }): string | undefined {
if (typeof value.jsonExample === "string") {
const maybeDatetime = this.getDateTime(value);
Expand Down
Loading