Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3c14b11
feat(ruby-sdk): support optional username/password in basic auth when…
Swimburger Mar 31, 2026
0a456d4
fix(ruby-sdk): use per-field omit checks and constructor optionality …
Swimburger Apr 1, 2026
152f413
fix(ruby-sdk): fix biome formatting for ternary expressions in constr…
Swimburger Apr 1, 2026
911da89
fix(ruby-sdk): remove omitted fields entirely from constructor params…
Swimburger Apr 2, 2026
05c7d3e
fix(ruby-sdk): skip auth header when both fields omitted and auth is …
Swimburger Apr 2, 2026
fcb906f
fix(ruby-sdk): use isFirstBlock to prevent else if without preceding …
Swimburger Apr 2, 2026
be380db
merge: resolve versions.yml conflict with main (bump to 1.1.13)
Swimburger Apr 2, 2026
23a7255
merge: resolve versions.yml conflict with main (bump to 1.1.14)
Swimburger Apr 2, 2026
bf8d60b
fix(ruby-sdk): use 'omit' instead of 'optional' in versions.yml chang…
Swimburger Apr 3, 2026
1eaea0e
refactor: rename basic-auth-optional fixture to basic-auth-pw-omitted
Swimburger Apr 3, 2026
662530d
fix(ruby-sdk): bump version to 1.2.0 (feat requires minor bump)
Swimburger Apr 3, 2026
5476179
Merge remote-tracking branch 'origin/main' into devin/1774997764-basi…
Swimburger Apr 3, 2026
38173e7
fix(ruby-sdk): remove unnecessary type casts for usernameOmit/passwor…
Swimburger Apr 3, 2026
139a33a
revert: restore type casts for usernameOmit/passwordOmit (needed for …
Swimburger Apr 3, 2026
c484fcb
fix(ruby-sdk): handle usernameOmit/passwordOmit in dynamic snippets g…
Swimburger Apr 3, 2026
ba92a72
refactor(ruby-sdk): simplify omit checks from === true to !!
Swimburger Apr 3, 2026
844fb07
fix: pass usernameOmit/passwordOmit through DynamicSnippetsConverter …
Swimburger Apr 3, 2026
f0e28f1
merge: resolve versions.yml conflict with main (bump to 1.3.0)
Swimburger Apr 3, 2026
893d218
merge: resolve conflicts with main (IR v66 upgrade, this.case.snakeSa…
Swimburger Apr 4, 2026
306b946
fix(ruby-sdk): remove omitted password field from basic-auth-pw-omitt…
Swimburger Apr 6, 2026
b2a8b98
merge: resolve versions.yml conflict with main (add 1.3.0-rc.1)
Swimburger Apr 7, 2026
2b5bd20
fix(ruby-sdk): remove cosmetic #{""} from generated auth header
Swimburger Apr 7, 2026
d36ba63
feat(ruby-sdk): add wire tests for basic-auth-pw-omitted fixture
Swimburger Apr 7, 2026
6a023f3
feat(ruby-sdk): add Authorization header assertion to wire tests
Swimburger Apr 7, 2026
77ee9f9
fix(ruby-sdk): skip auth header assertion when both username and pass…
Swimburger Apr 7, 2026
889e958
feat(mock-utils): add exact Authorization header matching to WireMock…
Swimburger Apr 7, 2026
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
32 changes: 22 additions & 10 deletions generators/ruby-v2/dynamic-snippets/src/EndpointSnippetGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,28 @@ export class EndpointSnippetGenerator {
auth: FernIr.dynamic.BasicAuth;
values: FernIr.dynamic.BasicAuthValues;
}): ruby.KeywordArgument[] {
return [
ruby.keywordArgument({
name: auth.username.snakeCase.safeName,
value: ruby.TypeLiteral.string(values.username)
}),
ruby.keywordArgument({
name: auth.password.snakeCase.safeName,
value: ruby.TypeLiteral.string(values.password)
})
];
// usernameOmit/passwordOmit may exist in newer IR versions
const authRecord = auth as unknown as Record<string, unknown>;
const usernameOmitted = !!authRecord.usernameOmit;
const passwordOmitted = !!authRecord.passwordOmit;
Comment on lines +216 to +219
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Dynamic IR BasicAuth type not updated — usernameOmit/passwordOmit smuggled via untyped object spread

The dynamic IR BasicAuth definition (packages/ir-sdk/fern/apis/ir-types-latest/definition/dynamic/auth.yml:22-25) only has username and password. The converter at DynamicSnippetsConverter.ts:736-749 smuggles usernameOmit/passwordOmit as extra runtime properties through Auth.basic()'s ...value spread (packages/ir-sdk/src/sdk/api/resources/dynamic/resources/auth/types/Auth.ts:50). The EndpointSnippetGenerator.ts:217 then reads them via as unknown as Record<string, unknown>. This is fragile: any code path that reconstructs the BasicAuth object (e.g., the V66→V65 IR migration at packages/cli/generation/ir-migrations/src/migrations/v66-to-v65/migrateFromV66ToV65.ts:1780-1783 which explicitly creates { username, password }) will silently drop these properties. The proper fix is to add usernameOmit and passwordOmit as optional fields on the dynamic IR's BasicAuth type definition, then regenerate the SDK types.

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.

This is a known limitation. The FernIr.dynamic.BasicAuth type from the dynamic IR SDK doesn't have typed usernameOmit/passwordOmit fields yet. The as unknown as Record<string, unknown> cast is necessary because the published SDK package lacks these properties even though they exist in the IR schema. Updating the dynamic IR types is out of scope for this PR per maintainer instruction ("Fix the non-IR changes"). The cast will be removed once the published IR SDK is updated to include these fields.

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.

Acknowledged — the proper fix is to add usernameOmit/passwordOmit to the dynamic IR's BasicAuth type definition. However, updating the dynamic IR type definition is an IR change, which is out of scope for this PR per maintainer instruction ("Fix the non-IR changes"). The cast is a deliberate workaround to avoid modifying the IR SDK types.

The DynamicSnippetsConverter now passes these fields through as extra properties on the dynamic IR data, and the dynamic snippets generators read them via cast. This is intentionally temporary — once the dynamic IR types are updated to include these fields (in a separate IR PR), these casts can be removed.

const args: ruby.KeywordArgument[] = [];
if (!usernameOmitted) {
args.push(
ruby.keywordArgument({
name: auth.username.snakeCase.safeName,
value: ruby.TypeLiteral.string(values.username)
})
);
}
if (!passwordOmitted) {
args.push(
ruby.keywordArgument({
name: auth.password.snakeCase.safeName,
value: ruby.TypeLiteral.string(values.password)
})
);
}
return args;
}

private getRootClientBearerAuthArgs({
Expand Down
99 changes: 67 additions & 32 deletions generators/ruby-v2/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,31 +116,59 @@ export class RootClientGenerator extends FileGenerator<RubyFile, SdkCustomConfig
writer.write(`headers = `);
writer.writeNode(this.getRawClientHeaders());
writer.newLine();
let isFirstBlock = true;
let emittedAnyBlock = false;
for (let i = 0; i < basicAuthSchemes.length; i++) {
const basicAuthScheme = basicAuthSchemes[i];
if (basicAuthScheme == null) {
continue;
}
const usernameName = this.case.snakeSafe(basicAuthScheme.username);
const passwordName = this.case.snakeSafe(basicAuthScheme.password);
const usernameOmitted = !!basicAuthScheme.usernameOmit;
const passwordOmitted = !!basicAuthScheme.passwordOmit;
// Build the credential string for Base64 encoding.
// Omitted fields become empty (e.g., password omitted → "#{username}:").
let credentialStr: string;
if (usernameOmitted && !passwordOmitted) {
credentialStr = `":#{${passwordName}}"`;
} else if (!usernameOmitted && passwordOmitted) {
credentialStr = `"#{${usernameName}}:"`;
} else {
credentialStr = `"#{${usernameName}}:#{${passwordName}}"`;
}
// Condition: only require non-omitted fields to be present
let condition: string;
if (!usernameOmitted && !passwordOmitted) {
condition = `!${usernameName}.nil? && !${passwordName}.nil?`;
} else if (usernameOmitted && !passwordOmitted) {
condition = `!${passwordName}.nil?`;
} else if (!usernameOmitted && passwordOmitted) {
condition = `!${usernameName}.nil?`;
} else {
// Both fields omitted — skip auth header entirely when auth is non-mandatory
continue;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Comment on lines +142 to +151
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.

Critical bug: When auth is required (isAuthOptional=false) and a single basic auth scheme has an omitted field, the generated code will unconditionally set the Authorization header even when the non-omitted field is nil.

For example, if passwordOmit=true and auth is required:

  • The condition check at line 140 (!${usernameName}.nil?) is calculated but never used
  • The code falls through to line 156's else branch which unconditionally sets the header
  • This generates: headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{""}")}"
  • If username is nil, this produces invalid Basic auth: "Basic #{Base64.strict_encode64("#{nil}:")}""Basic Og=="

Fix: When a field is omitted but auth is required, the condition check should still be applied:

if (!usernameOmitted && !passwordOmitted) {
    // Both required - check both or neither based on isAuthOptional
    condition = `!${usernameName}.nil? && !${passwordName}.nil?`;
} else if (usernameOmitted && !passwordOmitted) {
    condition = `!${passwordName}.nil?`;
} else if (!usernameOmitted && passwordOmitted) {
    condition = `!${usernameName}.nil?`;
} else {
    continue;
}

// Always use condition when there's a non-omitted field that could be nil
if (isAuthOptional || basicAuthSchemes.length > 1 || usernameOmitted || passwordOmitted) {
    // Use conditional logic
} else {
    // Both fields present and required
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

This comment came from an experimental review—please leave feedback if it was helpful/unhelpful. Learn more about experimental comments here.

if (isAuthOptional || basicAuthSchemes.length > 1) {
if (i === 0) {
writer.writeLine(`if !${usernameName}.nil? && !${passwordName}.nil?`);
if (isFirstBlock) {
writer.writeLine(`if ${condition}`);
} else {
writer.writeLine(`elsif !${usernameName}.nil? && !${passwordName}.nil?`);
writer.writeLine(`elsif ${condition}`);
}
isFirstBlock = false;
emittedAnyBlock = true;
writer.writeLine(
` headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameName}}:#{${passwordName}}")}"`
` headers["Authorization"] = "Basic #{Base64.strict_encode64(${credentialStr})}"`
);
if (i === basicAuthSchemes.length - 1) {
writer.writeLine(`end`);
}
} else {
writer.writeLine(
`headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameName}}:#{${passwordName}}")}"`
`headers["Authorization"] = "Basic #{Base64.strict_encode64(${credentialStr})}"`
);
}
}
if (emittedAnyBlock && (isAuthOptional || basicAuthSchemes.length > 1)) {
writer.writeLine(`end`);
}
}
writer.write(`@raw_client = `);
writer.writeNode(this.context.getRawClientClassReference());
Expand Down Expand Up @@ -344,30 +372,37 @@ export class RootClientGenerator extends FileGenerator<RubyFile, SdkCustomConfig
break;
}
case "basic": {
const usernameParam = ruby.parameters.keyword({
name: this.case.snakeSafe(scheme.username),
type: ruby.Type.string(),
initializer:
scheme.usernameEnvVar != null
? ruby.codeblock((writer) => {
writer.write(`ENV.fetch("${scheme.usernameEnvVar}", nil)`);
})
: undefined,
docs: undefined
});
parameters.push(usernameParam);
const passwordParam = ruby.parameters.keyword({
name: this.case.snakeSafe(scheme.password),
type: ruby.Type.string(),
initializer:
scheme.passwordEnvVar != null
? ruby.codeblock((writer) => {
writer.write(`ENV.fetch("${scheme.passwordEnvVar}", nil)`);
})
: undefined,
docs: undefined
});
parameters.push(passwordParam);
// When omit is true, the field is completely removed from the end-user API.
const usernameOmitted = !!scheme.usernameOmit;
const passwordOmitted = !!scheme.passwordOmit;
if (!usernameOmitted) {
const usernameParam = ruby.parameters.keyword({
name: this.case.snakeSafe(scheme.username),
type: ruby.Type.string(),
initializer:
scheme.usernameEnvVar != null
? ruby.codeblock((writer) => {
writer.write(`ENV.fetch("${scheme.usernameEnvVar}", nil)`);
})
: undefined,
docs: undefined
});
parameters.push(usernameParam);
}
if (!passwordOmitted) {
const passwordParam = ruby.parameters.keyword({
name: this.case.snakeSafe(scheme.password),
type: ruby.Type.string(),
initializer:
scheme.passwordEnvVar != null
? ruby.codeblock((writer) => {
writer.write(`ENV.fetch("${scheme.passwordEnvVar}", nil)`);
})
: undefined,
docs: undefined
});
parameters.push(passwordParam);
}
break;
}
case "inferred": {
Expand Down
41 changes: 39 additions & 2 deletions generators/ruby-v2/sdk/src/wire-tests/WireTestGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,12 @@ export class WireTestGenerator {
authParams.push(`${this.case.snakeSafe(scheme.name)}: "test-api-key"`);
break;
case "basic":
authParams.push(`${this.case.snakeSafe(scheme.username)}: "test-username"`);
authParams.push(`${this.case.snakeSafe(scheme.password)}: "test-password"`);
if (!scheme.usernameOmit) {
authParams.push(`${this.case.snakeSafe(scheme.username)}: "test-username"`);
}
if (!scheme.passwordOmit) {
authParams.push(`${this.case.snakeSafe(scheme.password)}: "test-password"`);
}
break;
case "oauth":
authParams.push('client_id: "test-client-id"');
Expand Down Expand Up @@ -348,6 +352,19 @@ export class WireTestGenerator {
lines.push(` query_params: ${queryParamsCode},`);
lines.push(` expected: 1`);
lines.push(` )`);

// Verify Authorization header when basic auth is configured
const expectedAuthHeader = this.buildExpectedAuthorizationHeader();
if (expectedAuthHeader != null) {
lines.push(``);
lines.push(` verify_authorization_header(`);
lines.push(` test_id: test_id,`);
lines.push(` method: "${endpoint.method}",`);
lines.push(` url_path: "${basePath}",`);
lines.push(` expected_value: "${expectedAuthHeader}"`);
lines.push(` )`);
}

lines.push(" end");

return lines;
Expand Down Expand Up @@ -532,6 +549,26 @@ export class WireTestGenerator {
return out;
}

/**
* Builds the expected Authorization header value for basic auth.
* Returns the full header value (e.g., "Basic dGVzdC11c2VybmFtZTp0ZXN0LXBhc3N3b3Jk")
* or null if no basic auth scheme is configured.
*/
private buildExpectedAuthorizationHeader(): string | null {
for (const scheme of this.context.ir.auth.schemes) {
if (scheme.type === "basic") {
if (scheme.usernameOmit && scheme.passwordOmit) {
continue;
}
const username = scheme.usernameOmit ? "" : "test-username";
const password = scheme.passwordOmit ? "" : "test-password";
const encoded = Buffer.from(`${username}:${password}`).toString("base64");
return `Basic ${encoded}`;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
}
return null;
}

private toPascalCase(str: string): string {
return str
.split("_")
Expand Down
25 changes: 25 additions & 0 deletions generators/ruby-v2/sdk/src/wire-tests/WireTestSetupGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,31 @@ class WireMockTestCase < Minitest::Test

assert_equal expected, requests.length, "Expected #{expected} requests, found #{requests.length}"
end

# Verifies that the Authorization header on captured requests matches the expected value.
#
# @param test_id [String] The test ID used to filter requests
# @param method [String] The HTTP method (GET, POST, etc.)
# @param url_path [String] The URL path to match
# @param expected_value [String] The expected Authorization header value
def verify_authorization_header(test_id:, method:, url_path:, expected_value:)
admin_url = ENV['WIREMOCK_URL'] ? "#{ENV['WIREMOCK_URL']}/__admin" : WIREMOCK_ADMIN_URL
uri = URI("#{admin_url}/requests/find")
http = Net::HTTP.new(uri.host, uri.port)
post_request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })

request_body = { "method" => method, "urlPath" => url_path }
request_body["headers"] = { "X-Test-Id" => { "equalTo" => test_id } }

post_request.body = request_body.to_json
response = http.request(post_request)
result = JSON.parse(response.body)
requests = result["requests"] || []

refute_empty requests, "No requests found for test_id #{test_id}"
actual_header = requests.first.dig("request", "headers", "Authorization")
assert_equal expected_value, actual_header, "Expected Authorization header '#{expected_value}', got '#{actual_header}'"
end
end
`;
}
Expand Down
12 changes: 12 additions & 0 deletions generators/ruby-v2/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json

- version: 1.4.0
changelogEntry:
- summary: |
Support omitting username or password from basic auth when configured via
`usernameOmit` or `passwordOmit` in the IR. Omitted fields are removed from
the SDK's public API and treated as empty strings internally (e.g., omitting
password encodes `username:`, omitting username encodes `:password`). When
both are omitted, the Authorization header is skipped entirely.
type: feat
createdAt: "2026-04-02"
irVersion: 66

- version: 1.3.0-rc.1
changelogEntry:
- summary: |
Expand Down
27 changes: 19 additions & 8 deletions packages/commons/mock-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface WireMockMapping {
request: {
urlPathTemplate: string;
method: string;
headers?: Record<string, { matches: string }>;
headers?: Record<string, { matches?: string; equalTo?: string }>;
pathParameters?: Record<string, { equalTo: string }>;
queryParameters?: Record<string, { equalTo: string }>;
formParameters?: Record<string, unknown>;
Expand Down Expand Up @@ -267,16 +267,27 @@ export class WireMock {
const shouldAddBodyPattern = needsBodyPattern && isSseResponse;

// Build auth header matchers for endpoints that require authentication.
// Skip auth header matching when endpoint has per-endpoint security because
// the client configures all auth schemes globally and header overwriting
// (e.g., multiple schemes writing to "Authorization") makes the exact value unpredictable.
const authHeaders: Record<string, { matches: string }> = {};
if (endpoint.auth && !(endpoint.security != null && endpoint.security.length > 0)) {
const authHeaders: Record<string, { matches?: string; equalTo?: string }> = {};
if (endpoint.auth) {
for (const scheme of ir.auth.schemes) {
switch (scheme.type) {
case "basic":
authHeaders["Authorization"] = { matches: "Basic .+" };
case "basic": {
// Compute exact Authorization header using test credentials.
// Access usernameOmit/passwordOmit via runtime property check
// (available in IR v63+ but @fern-fern/ir-sdk types may lag).
const schemeRecord = scheme as unknown as Record<string, unknown>;
const usernameOmit = schemeRecord.usernameOmit === true;
const passwordOmit = schemeRecord.passwordOmit === true;
if (usernameOmit && passwordOmit) {
// Both omitted — SDK skips the Authorization header entirely.
break;
}
const username = usernameOmit ? "" : "test-username";
const password = passwordOmit ? "" : "test-password";
const encoded = Buffer.from(`${username}:${password}`).toString("base64");
authHeaders["Authorization"] = { equalTo: `Basic ${encoded}` };
break;
}
case "bearer":
authHeaders["Authorization"] = { matches: "Bearer .+" };
break;
Expand Down
3 changes: 1 addition & 2 deletions seed/ruby-sdk-v2/basic-auth-pw-omitted/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading