Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
82 changes: 54 additions & 28 deletions generators/ruby-v2/sdk/src/root-client/RootClientGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,39 @@ export class RootClientGenerator extends FileGenerator<RubyFile, SdkCustomConfig
}
const usernameName = basicAuthScheme.username.snakeCase.safeName;
const passwordName = basicAuthScheme.password.snakeCase.safeName;
// usernameOmit/passwordOmit may exist in newer IR versions
const scheme = basicAuthScheme as unknown as Record<string, unknown>;
const usernameOmitted = scheme.usernameOmit === true;
const passwordOmitted = scheme.passwordOmit === true;

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

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.

🔴 Unnecessary as unknown as Record<string, unknown> cast bypasses type safety for usernameOmit/passwordOmit

FernIr.AuthScheme.Basic extends FernIr.BasicAuthScheme which already declares usernameOmit: boolean | undefined and passwordOmit: boolean | undefined (packages/ir-sdk/src/sdk/api/resources/auth/types/BasicAuthScheme.ts:10-15). The filter's type guard at line 105–106 narrows basicAuthSchemes elements to FernIr.AuthScheme & { type: "basic" }, which resolves to FernIr.AuthScheme.Basic. The as unknown as Record<string, unknown> cast is unnecessary and violates the repository rule in CLAUDE.md: "Never use as unknown as X. These are escape hatches that bypass the type system entirely. If the types don't line up, fix the types." The code can directly use basicAuthScheme.usernameOmit and basicAuthScheme.passwordOmit.

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 — the Ruby generator imports BasicAuthScheme from @fern-fern/ir-sdk v61 which doesn't have usernameOmit/passwordOmit in its type definitions, so basicAuthScheme.usernameOmit would be a type error at compile time. The as unknown as Record<string, unknown> cast is necessary for this IR version. Once the IR version is bumped to v63+, this cast can be replaced with direct access.

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 false positive. The Ruby generator imports from @fern-fern/ir-sdk@^61.7.0 (IR v61), not @fern-api/ir-sdk. The v61 BasicAuthScheme type does not have usernameOmit/passwordOmit fields — those were added in IR v63. The as unknown as Record<string, unknown> cast is necessary because the fields don't exist in the type definition at this IR version. Fixing this would require bumping the Ruby generator to IR v63, which is out of scope for this PR.

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
// Omitted fields use empty string directly
const usernameExpr = usernameOmitted ? `""` : usernameName;
const passwordExpr = passwordOmitted ? `""` : 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 {
condition = `true`;
}
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?`);
writer.writeLine(`if ${condition}`);
} else {
writer.writeLine(`elsif !${usernameName}.nil? && !${passwordName}.nil?`);
writer.writeLine(`elsif ${condition}`);
}
writer.writeLine(
` headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameName}}:#{${passwordName}}")}"`
` headers["Authorization"] = "Basic #{Base64.strict_encode64("#{${usernameExpr}}:#{${passwordExpr}}")}"`
);
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("#{${usernameExpr}}:#{${passwordExpr}}")}"`
);
}
}
Expand Down Expand Up @@ -342,30 +360,38 @@ export class RootClientGenerator extends FileGenerator<RubyFile, SdkCustomConfig
break;
}
case "basic": {
const usernameParam = ruby.parameters.keyword({
name: scheme.username.snakeCase.safeName,
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: scheme.password.snakeCase.safeName,
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 schemeRecord = scheme as unknown as Record<string, unknown>;
const usernameOmitted = schemeRecord.usernameOmit === true;
const passwordOmitted = schemeRecord.passwordOmit === true;

@devin-ai-integration devin-ai-integration Bot Apr 2, 2026

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.

🔴 Unnecessary as unknown as Record<string, unknown> cast in getAuthenticationParameters for basic auth omit fields

Inside case "basic" of the switch on scheme.type, TypeScript narrows scheme to FernIr.AuthScheme.Basic which extends FernIr.BasicAuthScheme — a type that already has usernameOmit: boolean | undefined and passwordOmit: boolean | undefined (packages/ir-sdk/src/sdk/api/resources/auth/types/BasicAuthScheme.ts:10-15). The as unknown as Record<string, unknown> cast is unnecessary and violates the repository rule in CLAUDE.md: "Never use as unknown as X." The code can directly use scheme.usernameOmit and scheme.passwordOmit.

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.

Same as above — false positive. The Ruby generator uses @fern-fern/ir-sdk@^61.7.0 (IR v61), which doesn't have usernameOmit/passwordOmit on BasicAuthScheme. The cast is required at this IR version.

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Outdated
if (!usernameOmitted) {
const usernameParam = ruby.parameters.keyword({
name: scheme.username.snakeCase.safeName,
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: scheme.password.snakeCase.safeName,
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
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.1.12
changelogEntry:
- summary: |
Support optional username and password in basic auth. The SDK now accepts
username-only, password-only, or both credentials. Missing fields are treated
as empty strings (e.g., username-only encodes `username:`, password-only
encodes `:password`). When neither is provided, the Authorization header is
omitted entirely.
type: feat
createdAt: "2026-03-31"
irVersion: 61

@devin-ai-integration devin-ai-integration Bot Mar 31, 2026

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.

🔴 irVersion 61 in versions.yml strips usernameOmit/passwordOmit fields, making the entire feature non-functional

The versions.yml entry for v1.1.14 declares irVersion: 61, but the usernameOmit and passwordOmit fields on BasicAuthScheme were introduced in IR v63. When the Fern CLI runs this generator, it migrates the IR down from latest to v61 using the migration chain. The v63-to-v62 migration at packages/cli/generation/ir-migrations/src/migrations/v63-to-v62/migrateFromV63ToV62.ts:140-147 explicitly strips these fields from the BasicAuthScheme. As a result, the generator will never receive usernameOmit or passwordOmit in production — the scheme.usernameOmit === true check (RootClientGenerator.ts:129-130) will always evaluate to false because the field is undefined after IR migration. The seed output in seed/ruby-sdk-v2/basic-auth-pw-omitted/lib/seed/client.rb confirms this: the generated client still takes password: as a required parameter despite the test definition setting omit: true. Additionally, the IR migration entry at v63-to-v62 has [GeneratorName.RUBY_SDK]: GeneratorWasNeverUpdatedToConsumeNewIR and needs to be updated to register version "1.1.14" so the CLI knows not to migrate down past v63 for this generator version.

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.

The IR version mismatch (v61 vs v63) is a known limitation and out of scope for this PR per discussion with the maintainer. The generator code is forward-compatible and will activate once the IR version is bumped in a separate PR.


- version: 1.1.11
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"additionalProperties": false,
"definitions": {}
}
7 changes: 7 additions & 0 deletions seed/ruby-sdk-v2/basic-auth-optional/.fern/metadata.json

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

75 changes: 75 additions & 0 deletions seed/ruby-sdk-v2/basic-auth-optional/.github/workflows/ci.yml

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

1 change: 1 addition & 0 deletions seed/ruby-sdk-v2/basic-auth-optional/.gitignore

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

69 changes: 69 additions & 0 deletions seed/ruby-sdk-v2/basic-auth-optional/.rubocop.yml

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

23 changes: 23 additions & 0 deletions seed/ruby-sdk-v2/basic-auth-optional/Gemfile

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

14 changes: 14 additions & 0 deletions seed/ruby-sdk-v2/basic-auth-optional/Gemfile.custom

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

Loading
Loading