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 @@ -57,7 +57,11 @@ export const BaseRubyCustomConfigSchema = z.object({
// scheme. Disabled by default so existing output is unchanged (OAuth env vars
// win over explicitly provided basic auth).
preferExplicitAuth: z.boolean().optional(),
retryStatusCodes: z.optional(z.enum(["legacy", "recommended"]))
retryStatusCodes: z.optional(z.enum(["legacy", "recommended"])),
// Opt-in: when the IR marks a referenced request body as optional, a caller that
// passes no body properties sends neither a body nor a Content-Type header.
// Disabled by default so existing output is byte-identical.
respectOptionalRequestBody: z.boolean().optional()
});

export type BaseRubyCustomConfigSchema = z.infer<typeof BaseRubyCustomConfigSchema>;
2 changes: 1 addition & 1 deletion generators/ruby-v2/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"@fern-api/fs-utils": "workspace:*",
"@fern-api/path-utils": "workspace:*",
"@fern-api/ruby-ast": "workspace:*",
"@fern-fern/ir-sdk": "67.15.0",
"@fern-fern/ir-sdk": "67.21.0",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"dedent": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,28 @@ class Request < <%= gem_namespace %>::Internal::Http::BaseRequest
# @param headers [Hash] Additional headers for the request (optional)
# @param query [Hash] Query parameters for the request (optional)
# @param body [Object, nil] The JSON request body (optional)
# @param request_options [<%= gem_namespace %>::RequestOptions, Hash{Symbol=>Object}, nil]
def initialize(base_url:, path:, method:, headers: {}, query: {}, body: nil, request_options: {})
<% if (respectOptionalRequestBody) { %> # @param omit_content_type_without_body [Boolean] When true and no body is present, the
# Content-Type header is omitted (used for endpoints whose request body is optional)
<% } %> # @param request_options [<%= gem_namespace %>::RequestOptions, Hash{Symbol=>Object}, nil]
def initialize(base_url:, path:, method:, headers: {}, query: {}, body: nil, <% if (respectOptionalRequestBody) { %>omit_content_type_without_body: false, <% } %>request_options: {})
super(base_url:, path:, method:, headers:, query:, request_options:)

@body = body
end
<% if (respectOptionalRequestBody) { %> @omit_content_type_without_body = omit_content_type_without_body
<% } %> end

# @return [Hash] The encoded HTTP request headers.
# @param protected_keys [Array<String>] Header keys set by the SDK client (e.g. auth, metadata)
# that must not be overridden by additional_headers from request_options.
def encode_headers(protected_keys: [])
sdk_headers = {
<% if (respectOptionalRequestBody) { %> sdk_headers = { "Accept" => "application/json" }
sdk_headers["Content-Type"] = "application/json" unless @omit_content_type_without_body && @body.nil?
sdk_headers = sdk_headers.merge(@headers)
<% } else { %> sdk_headers = {
"Content-Type" => "application/json",
"Accept" => "application/json"
}.merge(@headers)
merge_additional_headers(sdk_headers, protected_keys:)
<% } %> merge_additional_headers(sdk_headers, protected_keys:)
end

# @return [String, nil] The encoded HTTP request body.
Expand Down
9 changes: 9 additions & 0 deletions generators/ruby-v2/base/src/project/RubyProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export class RubyProject extends AbstractProject<AbstractRubyGeneratorContext<Ba
allowUserAgentAppInfo: this.rubyContext.customConfig.allowUserAgentAppInfo,
maxRetries: this.rubyContext.customConfig.maxRetries,
retryStatusCodes: this.rubyContext.customConfig.retryStatusCodes,
respectOptionalRequestBody: this.rubyContext.customConfig.respectOptionalRequestBody,
endpointSecurity: this.rubyContext.ir.auth.requirement === "ENDPOINT_SECURITY"
})
);
Expand All @@ -228,6 +229,7 @@ export class RubyProject extends AbstractProject<AbstractRubyGeneratorContext<Ba
allowUserAgentAppInfo,
maxRetries,
retryStatusCodes,
respectOptionalRequestBody,
endpointSecurity
}: {
filename: string;
Expand All @@ -239,6 +241,7 @@ export class RubyProject extends AbstractProject<AbstractRubyGeneratorContext<Ba
allowUserAgentAppInfo?: boolean;
maxRetries?: number;
retryStatusCodes?: string;
respectOptionalRequestBody?: boolean;
endpointSecurity?: boolean;
}): Promise<File> {
let rendered = replaceTemplate({
Expand All @@ -251,6 +254,7 @@ export class RubyProject extends AbstractProject<AbstractRubyGeneratorContext<Ba
includePlatformHeaders,
allowUserAgentAppInfo,
maxRetries,
respectOptionalRequestBody,
endpointSecurity
})
});
Expand Down Expand Up @@ -314,6 +318,7 @@ function getTemplateVariables({
includePlatformHeaders,
allowUserAgentAppInfo,
maxRetries,
respectOptionalRequestBody,
endpointSecurity
}: {
gemNamespace: string;
Expand All @@ -323,6 +328,7 @@ function getTemplateVariables({
includePlatformHeaders?: boolean;
allowUserAgentAppInfo?: boolean;
maxRetries?: number;
respectOptionalRequestBody?: boolean;
endpointSecurity?: boolean;
}): Record<string, unknown> {
return {
Expand All @@ -338,6 +344,9 @@ function getTemplateVariables({
// so flag-off raw_client.rb stays byte-identical.
allowUserAgentAppInfo: allowUserAgentAppInfo ?? false,
defaultMaxRetries: maxRetries ?? 2,
// Emits the JSON::Request omit_content_type_without_body parameter only when the
// opt-in flag is on, so flag-off json/request.rb stays byte-identical.
respectOptionalRequestBody: respectOptionalRequestBody ?? false,
// Emits the RawClient#auth_headers_for_endpoint delegator only for
// endpoint-security SDKs, so ALL/ANY SDKs see zero change to raw_client.rb.
endpointSecurity: endpointSecurity ?? false
Expand Down
2 changes: 1 addition & 1 deletion generators/ruby-v2/dynamic-snippets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@fern-api/browser-compatible-base-generator": "workspace:*",
"@fern-api/configs": "workspace:*",
"@fern-api/core-utils": "workspace:*",
"@fern-api/dynamic-ir-sdk": "66.1.0",
"@fern-api/dynamic-ir-sdk": "67.21.0",
"@fern-api/path-utils": "workspace:*",
"@fern-api/ruby-ast": "workspace:*",
"@types/lodash-es": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ export class EndpointSnippetGenerator {
);

// Add body fields as keyword arguments
if (request.body != null && snippet.requestBody != null) {
if (request.body != null && snippet.requestBody != null && !this.callOmitsRequestBody({ request, snippet })) {
switch (request.body.type) {
case "bytes":
// Not supported in Ruby snippets yet
Expand Down Expand Up @@ -746,6 +746,28 @@ export class EndpointSnippetGenerator {
return args;
}

/**
* When the generator is configured to respect optional request bodies and the IR marks
* the body as optional, a snippet whose example has no body omits the body arguments
* entirely rather than passing an empty object.
*/
private callOmitsRequestBody({
request,
snippet
}: {
request: FernIr.dynamic.BodyRequest;
snippet: FernIr.dynamic.EndpointSnippetRequest;
}): boolean {
if (this.context.customConfig?.respectOptionalRequestBody !== true) {
return false;
}
if (request.bodyRequired !== false) {
return false;
}
const value = snippet.requestBody;
return value == null || (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
}

private getBodyArgsForNonObjectType({
namedType,
typeRef,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FernIr } from "@fern-api/dynamic-ir-sdk";
import { AbsoluteFilePath, join } from "@fern-api/path-utils";

import { buildDynamicSnippetsGenerator } from "./utils/buildDynamicSnippetsGenerator.js";
import { buildGeneratorConfig } from "./utils/buildGeneratorConfig.js";

const DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY = AbsoluteFilePath.of(
`${__dirname}/../../../../../packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions`
);

const IR_FILEPATH = AbsoluteFilePath.of(
join(DYNAMIC_IR_TEST_DEFINITIONS_DIRECTORY, "respect-optional-request-body.json")
);

// `bulkRefund` takes a body the API does not require, so an example that supplies nothing for it
// reaches the snippet generator either as no body at all or as an empty one.
const bulkRefundWithoutBody: FernIr.dynamic.EndpointSnippetRequest = {
endpoint: {
method: "POST",
path: "/refunds"
},
baseURL: undefined,
environment: undefined,
auth: undefined,
pathParameters: undefined,
queryParameters: undefined,
headers: undefined,
requestBody: undefined
};

describe("optional request body", () => {
it("omits the body arguments once the generator opts in", async () => {
const generator = buildDynamicSnippetsGenerator({
irFilepath: IR_FILEPATH,
config: buildGeneratorConfig({ customConfig: { respectOptionalRequestBody: true } })
});

for (const requestBody of [undefined, {}]) {
const response = await generator.generate({ ...bulkRefundWithoutBody, requestBody });

expect(response.errors).toBeUndefined();
expect(response.snippet).toContain("client.bulk_refund");
expect(response.snippet).not.toContain("amount");
}
});

it("keeps passing a supplied body", async () => {
const generator = buildDynamicSnippetsGenerator({
irFilepath: IR_FILEPATH,
config: buildGeneratorConfig({ customConfig: { respectOptionalRequestBody: true } })
});

const response = await generator.generate({ ...bulkRefundWithoutBody, requestBody: { amount: 60 } });

expect(response.errors).toBeUndefined();
expect(response.snippet).toContain("amount: 60");
});
});
2 changes: 1 addition & 1 deletion generators/ruby-v2/model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@fern-api/fs-utils": "workspace:*",
"@fern-api/ruby-ast": "workspace:*",
"@fern-api/ruby-base": "workspace:*",
"@fern-fern/ir-sdk": "67.15.0",
"@fern-fern/ir-sdk": "67.21.0",
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- summary: |
Endpoints whose request body is a referenced type no longer serialize path parameters into
the JSON body. With `respectOptionalRequestBody`, an omitted optional body on such an endpoint
now sends no body and no `Content-Type` instead of a body containing the path parameters.
type: fix
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- summary: |
Add the `respectOptionalRequestBody` configuration option. When enabled, an endpoint whose
request body the API does not require lets callers omit the body: the request then carries
neither a body nor a `Content-Type` header, instead of sending `{}` as `application/json`.
Disabled by default.
type: feat
3 changes: 2 additions & 1 deletion generators/ruby-v2/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"devDependencies": {
"@fern-api/base-generator": "workspace:*",
"@fern-api/configs": "workspace:*",
"@fern-api/dynamic-ir-sdk": "67.21.0",
"@fern-api/core-utils": "workspace:*",
"@fern-api/fs-utils": "workspace:*",
"@fern-api/logger": "workspace:*",
Expand All @@ -53,7 +54,7 @@
"@fern-api/ruby-model": "workspace:*",
"@fern-fern/generator-cli-sdk": "^0.1.5",
"@fern-fern/generator-exec-sdk": "catalog:",
"@fern-fern/ir-sdk": "67.15.0",
"@fern-fern/ir-sdk": "67.21.0",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"dedent": "catalog:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export class HttpEndpointGenerator {
queryBagReference: queryParameterCodeBlock?.queryParameterBagReference,
headerBagReference,
bodyReference: requestBodyCodeBlock?.requestBodyReference,
omitContentTypeWithoutBody: requestBodyCodeBlock?.omitContentTypeWithoutBody,
baseUrlName
});

Expand Down
8 changes: 7 additions & 1 deletion generators/ruby-v2/sdk/src/endpoint/http/RawClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export declare namespace RawClient {
endpoint: FernIr.HttpEndpoint;
/** reference to a variable that is the body */
bodyReference?: ruby.CodeBlock;
/** whether the request must omit Content-Type when the body reference is nil */
omitContentTypeWithoutBody?: boolean;
/** the path parameter id to reference */
pathParameterReferences: Record<string, string>;
/** the headers to pass to the endpoint */
Expand Down Expand Up @@ -48,7 +50,8 @@ export class RawClient {
headerBagReference,
queryBagReference,
requestType,
baseUrlName
baseUrlName,
omitContentTypeWithoutBody
}: RawClient.CreateHttpRequestWrapperArgs): ruby.CodeBlock | undefined {
switch (requestType) {
case "json":
Expand All @@ -75,6 +78,9 @@ export class RawClient {
if (bodyReference != null) {
writer.writeLine(`body: ${bodyReference},`);
}
if (omitContentTypeWithoutBody === true && requestType === "json") {
writer.writeLine(`omit_content_type_without_body: true,`);
}
writer.writeLine(`request_options: request_options`);
writer.dedent();
writer.write(`)`);
Expand Down
59 changes: 58 additions & 1 deletion generators/ruby-v2/sdk/src/endpoint/request/EndpointRequest.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { CaseConverter } from "@fern-api/base-generator";
import { CaseConverter, GeneratorError } from "@fern-api/base-generator";
import { ruby } from "@fern-api/ruby-ast";
import { FernIr } from "@fern-fern/ir-sdk";
import { SdkGeneratorContext } from "../../SdkGeneratorContext.js";
import { isUrlEncodedRequestBody } from "../../utils/requestBody.js";
import { RawClient } from "../http/RawClient.js";

export const BODY_BAG_NAME = "body_params";
export const PATH_PARAM_NAMES_VN = "path_param_names";

export interface QueryParameterCodeBlock {
code: ruby.CodeBlock;
queryParameterBagReference: string;
Expand All @@ -17,6 +21,11 @@ export interface HeaderParameterCodeBlock {
export interface RequestBodyCodeBlock {
code?: ruby.CodeBlock;
requestBodyReference: ruby.CodeBlock;
/**
* True when the body reference evaluates to nil for callers that pass no body,
* in which case the request must omit the Content-Type header as well.
*/
omitContentTypeWithoutBody?: boolean;
}

export abstract class EndpointRequest {
Expand All @@ -40,6 +49,47 @@ export abstract class EndpointRequest {

public abstract getParameterType(): ruby.Type;

/**
* True when the IR marks the referenced JSON request body as optional and the generator
* is configured to let callers omit it entirely. Form-urlencoded bodies are excluded
* because their request class always sends a form content type.
*/
protected respectsOptionalRequestBody(): boolean {
const requestBody = this.endpoint.requestBody;
return (
this.context.customConfig.respectOptionalRequestBody === true &&
requestBody != null &&
requestBody.type === "reference" &&
requestBody.required === false &&
!isUrlEncodedRequestBody(requestBody)
);
}
Comment thread
willkendall01 marked this conversation as resolved.

/**
* Writes `<bodyVariableName>.empty? ? nil : ` so that an omitted optional body
* becomes a nil body rather than an empty object.
*/
protected writeOptionalBodyGuard(writer: ruby.Writer, bodyVariableName: string): void {
writer.write(`${bodyVariableName}.empty? ? nil : `);
}

protected getPathParameterNames(): string[] {
return this.endpoint.allPathParameters.map((pathParameter) => this.case.snakeSafe(pathParameter.name));
}

protected hasPathParameters(): boolean {
return this.endpoint.allPathParameters.length > 0;
}

/**
* Writes the statements that split the path parameters out of `params`, so that the
* request body only carries the properties the endpoint actually declares as body fields.
*/
protected writePathParameterExclusion(writer: ruby.Writer): void {
writer.writeLine(`${PATH_PARAM_NAMES_VN} = ${toRubySymbolArray(this.getPathParameterNames())}`);
writer.writeLine(`${BODY_BAG_NAME} = params.except(*${PATH_PARAM_NAMES_VN})`);
}

/**
* Follows alias-of-named chains to the terminal type id so request bodies
* declared as aliases of objects are serialized through the aliased class
Expand Down Expand Up @@ -67,3 +117,10 @@ export abstract class EndpointRequest {

public abstract getRequestType(): RawClient.RequestBodyType | undefined;
}

export function toRubySymbolArray(names: string[]): string {
if (names.some((name) => name.includes(" "))) {
throw GeneratorError.internalError("Symbol array cannot contain spaces");
}
return `%i[${names.join(" ")}]`;
}
Loading