Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,31 @@ export class OperationConverter extends AbstractOperationConverter {
}
}

// When there are still no endpoint-level examples but multiple content types
// have per-body v2Examples (from native OAS per-mediaType examples on bodies
// WITH schemas), elevate them to endpoint-level examples tagged with their
// content type. This enables docs to display per-content-type examples.
// Only fires for multi-content-type endpoints; single-content-type endpoints
// are handled by the normal v1→v2 example synthesis pipeline.
if (
Object.keys(fernExamples.examples).length === 0 &&
Object.keys(fernExamples.streamExamples).length === 0 &&
convertedRequestBodies != null &&
convertedRequestBodies.length > 1
) {
const perContentTypeExamples = this.synthesizeEndpointExamplesFromPerContentTypeRequestBodies({
convertedRequestBodies,
httpPath: path,
httpMethod,
baseUrl
});
if (perContentTypeExamples != null) {
for (const [name, example] of Object.entries(perContentTypeExamples)) {
fernExamples.examples[name] = example;
}
}
}

const endpointLevelSecuritySchemes = new Set<string>(
this.operation.security?.flatMap((securityRequirement) => Object.keys(securityRequirement)) ?? []
);
Expand Down Expand Up @@ -817,6 +842,7 @@ export class OperationConverter extends AbstractOperationConverter {
if (resolvedValue != null) {
result[name] = {
displayName: undefined,
contentType: undefined,
request: {
docs: undefined,
endpoint: {
Expand Down Expand Up @@ -883,6 +909,129 @@ export class OperationConverter extends AbstractOperationConverter {
return undefined;
}

/**
* Synthesizes endpoint-level v2Examples from per-content-type request body
* examples that have already been extracted by the RequestBodyConverter.
*
* This handles the case where multiple content types (e.g. application/json
* and application/ld+json) reference the same schema but have distinct
* per-content-type examples. The RequestBodyConverter correctly extracts
* these into each body's v2Examples, but they need to be elevated to
* endpoint-level examples (tagged with contentType) for docs rendering.
*/
private synthesizeEndpointExamplesFromPerContentTypeRequestBodies({
convertedRequestBodies,
httpPath,
httpMethod,
baseUrl
}: {
convertedRequestBodies: { requestBody: FernIr.HttpRequestBody }[];
httpPath: HttpPath;
httpMethod: FernIr.HttpMethod;
baseUrl: string | undefined;
}): Record<string, FernIr.V2HttpEndpointExample> | undefined {
const result: Record<string, FernIr.V2HttpEndpointExample> = {};
const responseExamplesByContentType = this.extractNativeResponseExamplesByContentType();

for (const convertedBody of convertedRequestBodies) {
const body = convertedBody.requestBody;
const bodyContentType = body.contentType;
const bodyV2Examples = body.v2Examples;

if (bodyV2Examples == null) {
continue;
}

const userExamples = bodyV2Examples.userSpecifiedExamples;
if (Object.keys(userExamples).length === 0) {
continue;
}

// Find a matching response example for this content type
const matchedResponse =
bodyContentType != null ? responseExamplesByContentType.get(bodyContentType) : undefined;

for (const [exampleName, exampleValue] of Object.entries(userExamples)) {
// Use a unique key that includes the content type to avoid collisions
const key =
convertedRequestBodies.length > 1 && bodyContentType != null
? `${exampleName} (${bodyContentType})`
: exampleName;

result[key] = {
displayName: convertedRequestBodies.length > 1 ? key : undefined,
contentType: bodyContentType ?? undefined,
request: {
docs: undefined,
endpoint: {
method: httpMethod,
path: this.buildExamplePath(httpPath, {})
},
baseUrl: undefined,
environment: baseUrl,
auth: undefined,
pathParameters: {},
queryParameters: {},
headers: {},
requestBody: exampleValue
},
response: matchedResponse ?? this.extractNativeResponseExample() ?? undefined,
codeSamples: undefined
};
}
}

return Object.keys(result).length > 0 ? result : undefined;
}

/**
* Extracts native response examples grouped by content type from the
* operation's successful (2xx) responses. Used by
* synthesizeEndpointExamplesFromPerContentTypeRequestBodies to match
* response examples to their corresponding request content types.
*/
private extractNativeResponseExamplesByContentType(): Map<string, FernIr.V2HttpEndpointResponse> {
const responsesByContentType = new Map<string, FernIr.V2HttpEndpointResponse>();
if (this.operation.responses == null) {
return responsesByContentType;
}
for (const [statusCode, response] of Object.entries(this.operation.responses)) {
const statusCodeNum = parseInt(statusCode);
if (isNaN(statusCodeNum) || statusCodeNum < 200 || statusCodeNum >= 300) {
continue;
}
const resolvedResponse = this.context.resolveMaybeReference<OpenAPIV3_1.ResponseObject>({
schemaOrReference: response,
breadcrumbs: [...this.breadcrumbs, "responses", statusCode]
});
if (resolvedResponse?.content == null) {
continue;
}
for (const [responseContentType, responseMediaType] of Object.entries(resolvedResponse.content)) {
if (responsesByContentType.has(responseContentType)) {
continue;
}
const namedExamples = this.context.getNamedExamplesFromMediaTypeObject({
mediaTypeObject: responseMediaType,
breadcrumbs: [...this.breadcrumbs, "responses", statusCode],
defaultExampleName: "Example"
});
for (const [, example] of namedExamples) {
const resolvedValue = this.context.resolveExampleWithValue(example);
if (resolvedValue != null) {
responsesByContentType.set(responseContentType, {
docs: undefined,
statusCode: statusCodeNum,
body: FernIr.V2HttpEndpointResponseBody.json(resolvedValue)
});
break;
}
}
}
}
return responsesByContentType;
}

private convertStreamConditionExamples({
httpPath,
httpMethod,
Expand Down Expand Up @@ -935,6 +1084,7 @@ export class OperationConverter extends AbstractOperationConverter {
this.getExampleName({ example, exampleIndex }),
{
displayName: undefined,
contentType: undefined,
request:
example.request != null ||
example["path-parameters"] != null ||
Expand Down

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.

🚩 Pre-existing counter bug in OpenRPC example conversion causes unnamed examples to overwrite each other

The ++i increment at packages/cli/api-importers/openrpc-to-ir/src/1.x/methods/MethodConverter.ts:360 is placed outside the for loop (lines 275–358) but inside the if block. This means i stays at 0 for every iteration of the loop. When multiple examples lack an explicit name, they all receive the fallback key "Example 1" (line 305: resolvedExample.name ?? \Example ${i + 1}`), and each subsequent unnamed example silently overwrites the previous one in the examplesrecord. Only the last unnamed example survives. This is a pre-existing bug not introduced by this PR (which only addedcontentType: undefined` at line 330), but it's in the same function and worth noting for a follow-up fix.

(Refers to line 360)

Open in Devin Review

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

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 catch — this is indeed a pre-existing bug (the ++i is outside the loop). This PR only added contentType: undefined at line 330 and doesn't change the iteration logic. I'll note this for a follow-up fix.

Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ export class MethodConverter extends AbstractConverter<OpenRPCConverterContext3_
// Create the example with request and response
examples[exampleName] = {
displayName: undefined,
contentType: undefined,
request: {
docs: undefined,
endpoint: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- summary: |
Extract per-content-type OpenAPI examples into endpoint-level v2Examples
tagged with their content type. When an operation declares multiple content
types (e.g. application/json and application/ld+json) with distinct examples,
the importer now elevates those examples so docs can display the correct
example body when the user switches content types.
type: feat
Original file line number Diff line number Diff line change
Expand Up @@ -39378,6 +39378,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"0": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -40514,6 +40515,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"1": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -41650,6 +41652,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"2": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -42788,6 +42791,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"3": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -43939,6 +43943,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"4": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -44063,6 +44068,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"5": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -45264,6 +45270,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"0": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -47494,6 +47501,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"1": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -48683,6 +48691,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"2": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -49875,6 +49884,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"3": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down Expand Up @@ -51116,6 +51126,7 @@ exports[`fern protoc-gen-fern > test with buf 1`] = `
"autogeneratedExamples": {
"4": {
"displayName": null,
"contentType": null,
"request": {
"endpoint": {
"method": "POST",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": [
"name",
"email"
],
"additionalProperties": false,
"definitions": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"type": "object",
"properties": {
"client": {
"oneOf": [
{
"$ref": "#/definitions/ClientWithId"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"definitions": {
"ClientWithId": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": [
"id",
"name",
"email"
],
"additionalProperties": false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
},
"required": [
"id",
"name",
"email"
],
"additionalProperties": false,
"definitions": {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FernIr, V2EndpointLocationHttpMethod, V2HttpEndpointExample } from "@fe
export function initializeEmptyServiceExample(): V2HttpEndpointExample {
return {
displayName: undefined,
contentType: undefined,
request: {
endpoint: {
method: V2EndpointLocationHttpMethod.Post,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
"userSpecifiedExamples": {
"addPlantExample_Successfully created plant_200": {
"codeSamples": undefined,
"contentType": undefined,
"displayName": "Successfully created plant",
"request": {
"auth": undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,7 @@
"userSpecifiedExamples": {
"addPlantExample_Successfully created plant_200": {
"codeSamples": undefined,
"contentType": undefined,
"displayName": "Successfully created plant",
"request": {
"auth": undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
"autogeneratedExamples": {
"anyOfExamplesAnyOfExampleNoTitlesExample_200": {
"codeSamples": undefined,
"contentType": undefined,
"displayName": "anyOfExamplesAnyOfExampleNoTitlesExample",
"request": {
"auth": undefined,
Expand Down
Loading
Loading