Skip to content
Open
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
28 changes: 28 additions & 0 deletions ruby/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ PATH
faraday (>= 1.0.1, < 3.0)
faraday-multipart (~> 1.0)
marcel (~> 1.0)
oauth2 (~> 2.0)

GEM
remote: https://rubygems.org/
specs:
anonymous_loader (0.1.3)
version_gem (~> 1.1, >= 1.1.14)
ast (2.4.3)
auth-sanitizer (0.2.3)
version_gem (~> 1.1, >= 1.1.14)
base64 (0.3.0)
bigdecimal (4.1.2)
byebug (13.0.0)
reline (>= 0.6.0)
coderay (1.1.3)
Expand All @@ -22,16 +29,32 @@ GEM
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
hashie (5.1.0)
logger
io-console (0.8.2)
json (2.21.1)
jwt (3.2.0)
base64
language_server-protocol (3.17.0.6)
lint_roller (1.1.0)
logger (1.7.0)
marcel (1.2.1)
method_source (1.1.0)
multi_xml (0.9.1)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
net-http (0.9.1)
uri (>= 0.11.1)
oauth2 (2.0.25)
anonymous_loader (~> 0.1, >= 0.1.3)
auth-sanitizer (~> 0.2, >= 0.2.3)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.7)
version_gem (~> 1.1, >= 1.1.14)
parallel (1.28.0)
parser (3.3.12.0)
ast (~> 2.4.1)
Expand All @@ -45,6 +68,7 @@ GEM
byebug (~> 13.0)
pry (>= 0.13, < 0.17)
racc (1.8.1)
rack (3.2.6)
rainbow (3.1.1)
rake (13.0.6)
regexp_parser (2.12.0)
Expand Down Expand Up @@ -78,10 +102,14 @@ GEM
parser (>= 3.3.7.2)
prism (~> 1.7)
ruby-progressbar (1.13.0)
snaky_hash (2.0.7)
hashie (>= 0.1.0, < 6)
version_gem (~> 1.1, >= 1.1.14)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
version_gem (1.1.15)

PLATFORMS
arm64-darwin-25
Expand Down
95 changes: 93 additions & 2 deletions ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,99 @@ api = F::Api.new(api_key: "YOUR_KEY")
api = F::Api.new(token: "YOUR_BEARER_TOKEN")
```

When an argument is omitted, the client falls back to environment variables:
`FACTORIAL_API_KEY` for the API key, `FACTORIAL_TOKEN` for the token.
When **no credential is passed at all**, the client falls back to the
`FACTORIAL_API_KEY` / `FACTORIAL_TOKEN` environment variables. Passing any
credential explicitly disables the env fallback entirely, so a leftover
exported variable can never ride along with (or veto) the credential you
actually chose.

### Inspecting a token

Factorial credentials are opaque strings that happen to be JWTs. `F::Api::Token`
decodes one β€” **without verifying its signature**; verification is the
server's job β€” so you can read its claims and plan refreshes:

```ruby
token = F::Api::Token.new(ENV["FACTORIAL_TOKEN"])

token.claims # => {"exp" => 1767225600, "cid" => "42", ...}
token[:cid] # => "42"
token.expires_at # => 2026-01-01 00:00:00 UTC
token.expired? # => false
token.expiring_soon?(margin: 120) # => true within 2 minutes of expiry
```

A credential that isn't a decodable JWT is handled gracefully: `claims` is
empty, `expires_at` is `nil`, and `expired?` never reports true β€” the API
remains the authority on whether it works.

### OAuth (managed token lifecycle)

For OAuth2 integrations, `F::Api::OAuth` covers the whole lifecycle: authorize
URL, code exchange, decoding, proactive and reactive refresh, and rotation:

```ruby
oauth = F::Api::OAuth.new(client_id: "...", client_secret: "...")
# Falls back to FACTORIAL_OAUTH_CLIENT_ID / FACTORIAL_OAUTH_CLIENT_SECRET.

# 1. Send the user to authorize (browser step, by design):
oauth.authorize_url(redirect_uri: "https://myapp.com/callback")

# 2. Exchange the code your callback receives (single-use, ~10 min):
tokens = oauth.exchange_code(params[:code], redirect_uri: "https://myapp.com/callback")

# 3. Wrap the tokens in a self-refreshing session:
session = oauth.session(tokens) do |rotated|
# Refresh tokens are SINGLE-USE: each refresh invalidates the previous
# one. Persist the new one here, or the chain breaks.
save_refresh_token!(rotated.refresh_token)
end

api = F::Api.new(oauth: session)
api.employees_employee.employees_employees_get(true, false) # required params are positional
```

The session checks the access token before every request and refreshes it
when it is within `margin:` seconds of expiry (default 60, configurable via
`oauth.session(tokens, margin: 120)`), judged by the token endpoint's
`expires_in` β€” so it works even if the access token is not a JWT. Expiry is
only an upper bound (a token can be revoked at any time), so if the API
still rejects the bearer with a 401, the client refreshes reactively and
retries that request once. Token endpoint failures raise `F::Api::OAuthError`,
which carries the HTTP `code` and parsed `body`.

### Bring your own token source

`oauth:` is duck-typed: any object that responds to `access_token` and
returns the bearer string works β€” the built-in session is just the
batteries-included implementation. This is the composition seam for other
token sources (your own cache or vault, or another Factorial gem's token
client) without coupling them to this gem:

```ruby
class MyTokenSource
def access_token = fetch_current_token_from_somewhere
end

api = F::Api.new(oauth: MyTokenSource.new)
```

A source that also responds to `refresh_after_reject!(rejected_bearer)` β€”
returning whether it now holds a different bearer β€” opts into the built-in
401 refresh-and-retry.

For the common "forward the caller's token" case there is a shortcut:
`access_token:` takes any callable, so one shared client can act on behalf
of whoever is making the current request:

```ruby
api = F::Api.new(access_token: -> { Current.factorial_token })
```

Both forms are consulted on every request β€” sometimes more than once per
request β€” so keep them cheap, idempotent, and thread-safe. `token:`,
`oauth:` and `access_token:` are mutually exclusive: each is a different
way of supplying the same `Authorization: Bearer` header.

### Custom base URL

Expand Down
1 change: 1 addition & 0 deletions ruby/factorial_api.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Gem::Specification.new do |s|
s.add_dependency 'faraday', '>= 1.0.1', '< 3.0'
s.add_dependency 'faraday-multipart', '~> 1.0'
s.add_dependency 'marcel', '~> 1.0'
s.add_dependency 'oauth2', '~> 2.0'

s.files = Dir.glob('lib/**/*.rb') + %w[README.md]
s.executables = []
Expand Down
113 changes: 106 additions & 7 deletions ruby/lib/factorial_api/api.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# frozen_string_literal: true

require 'uri'
require 'factorial_api/oauth'
require 'factorial_api/pagination'
require 'factorial_api/token'
require 'factorial_api/webhooks'

module F
Expand Down Expand Up @@ -42,15 +44,48 @@ def auth_settings
end
end

# Every generated method funnels through ApiClient#call_api, which makes
# this override the SDK's request wrapper: the one seat for cross-cutting
# transport behavior. Today that is reauthentication β€” expiry-based
# refresh cannot see revocation, so a 401 (the API's authoritative "this
# bearer is dead") triggers one reactive refresh and one retry when the
# credential source can mint a replacement. Safe even for writes: a 401
# is rejected at authentication, before the action runs. Static
# credentials fail exactly as before.
class RefreshingClient < ApiClient
def initialize(config, session:)
super(config)
@session = session
end

def call_api(http_method, path, opts = {})
sent = @session&.access_token
super
rescue ApiError => e
# A rescue clause does not cover its own body: a second 401 (or a
# failed refresh) propagates instead of looping.
raise unless e.code == 401 && @session&.refresh_after_reject!(sent)

super
end
end

attr_reader :client

def initialize(api_key: ENV.fetch('FACTORIAL_API_KEY', nil), token: ENV.fetch('FACTORIAL_TOKEN', nil),
base_url: ENV.fetch('FACTORIAL_BASE_URL', nil))
config = Config.new
config.api_key['x-api-key'] = api_key
config.access_token = token if token
apply_base_url(config, base_url) if base_url
@client = ApiClient.new(config)
def initialize(api_key: nil, token: nil, base_url: ENV.fetch('FACTORIAL_BASE_URL', nil),
oauth: nil, access_token: nil)
api_key = presence(api_key)
token = presence(token)
# Env credentials are a zero-config convenience, not a supplement:
# passing any credential explicitly disables them, so an exported
# FACTORIAL_TOKEN can neither veto nor ride along with an
# oauth:/access_token: the caller actually chose.
if [api_key, token, oauth, access_token].compact.empty?
api_key = presence(ENV.fetch('FACTORIAL_API_KEY', nil))
token = presence(ENV.fetch('FACTORIAL_TOKEN', nil))
end
validate_credentials!(api_key, token, oauth, access_token)
@client = build_client(build_config(api_key, token, oauth, access_token, presence(base_url)), oauth)
@apis = {}
end

Expand All @@ -62,6 +97,70 @@ def initialize(api_key: ENV.fetch('FACTORIAL_API_KEY', nil), token: ENV.fetch('F

private

def presence(value)
value unless value.nil? || value.to_s.strip.empty?
end

def validate_credentials!(api_key, token, oauth, access_token)
validate_bearer_sources!(oauth, access_token)
bearers = { token: token, oauth: oauth, access_token: access_token }.compact.keys
if bearers.size > 1
raise ArgumentError, "#{bearers.join(' and ')} are mutually exclusive β€” each supplies the bearer"
end
return if api_key || bearers.any?

raise ArgumentError,
'provide api_key, token, oauth, or access_token (or set FACTORIAL_API_KEY / FACTORIAL_TOKEN)'
end

# Misuse of the duck-typed sources fails here, at the constructor,
# instead of as a NoMethodError buried mid-request.
def validate_bearer_sources!(oauth, access_token)
if oauth && !oauth.respond_to?(:access_token)
raise ArgumentError,
'oauth must respond to #access_token β€” for a static string use token:, for a callable use access_token:'
end
return unless access_token && !access_token.respond_to?(:call)

raise ArgumentError, 'access_token must be callable β€” for a static string use token:'
end

# Only a source that can mint a replacement bearer opts into the 401
# retry; the contract is duck-typed like `oauth:` itself.
def build_client(config, oauth)
session = oauth if oauth.respond_to?(:refresh_after_reject!)
RefreshingClient.new(config, session: session)
end

def build_config(api_key, token, oauth, access_token, base_url)
config = Config.new
config.api_key['x-api-key'] = api_key
config.access_token = token if token
bearer_source = oauth || access_token
config.access_token_getter = bearer_getter(bearer_source) if bearer_source
apply_base_url(config, base_url) if base_url
config
end

# Consulted by the generated client on EVERY request (via
# Configuration#access_token_with_refresh) β€” what lets an oauth session
# refresh mid-flight and an access_token callable forward the caller's
# token. May fire more than once per request (auth_settings is rebuilt
# per auth scheme), so sources must stay cheap and idempotent. A source
# that yields no token raises rather than letting the request leave
# unauthenticated (the empty Bearer header would be dropped silently).
def bearer_getter(source)
lambda do
value = (source.respond_to?(:call) ? source.call : source.access_token).to_s
if value.strip.empty?
raise 'F::Api: the oauth/access_token source returned no token β€” ' \
'refusing to send an unauthenticated request'
end

value
end
end

def apply_base_url(config, base_url)
uri = parse_base_url(base_url)

Expand Down
Loading