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
8 changes: 7 additions & 1 deletion lib/restate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ def config
def client
cfg = config
Client.new(ingress_url: cfg.ingress_url, admin_url: cfg.admin_url,
ingress_headers: cfg.ingress_headers, admin_headers: cfg.admin_headers)
ingress_headers: resolve_headers(cfg.ingress_headers),
admin_headers: resolve_headers(cfg.admin_headers))
end

# @!visibility private
def resolve_headers(headers)
headers.respond_to?(:call) ? headers.call : headers
end

# ── Context accessor (internal) ──
Expand Down
11 changes: 11 additions & 0 deletions lib/restate/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@ class Config
attr_accessor :admin_url

# Default headers sent with every ingress request.
# Can be a Hash or a callable (Proc/Lambda) returning a Hash.
# A callable is evaluated each time +Restate.client+ is called,
# which lets frameworks like Rails inject per-request context
# (e.g., team ID, shard routing, auth tokens).
#
# @example Static headers
# config.ingress_headers = { "Authorization" => "Bearer tok" }
#
# @example Dynamic headers
# config.ingress_headers = -> { { "X-Team-Id" => Current.team_id } }
attr_accessor :ingress_headers

# Default headers sent with every admin request.
# Accepts the same static Hash or callable forms as +ingress_headers+.
attr_accessor :admin_headers

def initialize
Expand Down
127 changes: 127 additions & 0 deletions spec/callable_headers_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

require "spec_helper"
require "restate/config"
require "restate/client"

# Provide Restate.config / .configure / .client / .resolve_headers since we
# can't load the full module (native extension not compiled in test).
module Restate
class << self
def config
@config ||= Config.new
end

def configure
yield config
end

def client
cfg = config
Client.new(ingress_url: cfg.ingress_url, admin_url: cfg.admin_url,
ingress_headers: resolve_headers(cfg.ingress_headers),
admin_headers: resolve_headers(cfg.admin_headers))
end

def resolve_headers(headers)
headers.respond_to?(:call) ? headers.call : headers
end

def reset_config!
@config = nil
end
end
end

RSpec.describe "Callable headers support" do
before { Restate.reset_config! }
after { Restate.reset_config! }

describe "Restate.resolve_headers" do
it "returns a Hash as-is" do
h = { "Foo" => "Bar" }
expect(Restate.resolve_headers(h)).to equal(h)
end

it "calls a lambda and returns the result" do
callable = -> { { "Dynamic" => "yes" } }
expect(Restate.resolve_headers(callable)).to eq({ "Dynamic" => "yes" })
end

it "calls a Proc and returns the result" do
callable = proc { { "From" => "proc" } }
expect(Restate.resolve_headers(callable)).to eq({ "From" => "proc" })
end

it "works with any object responding to #call" do
header_provider = Object.new
def header_provider.call
{ "Custom" => "provider" }
end

expect(Restate.resolve_headers(header_provider)).to eq({ "Custom" => "provider" })
end
end

describe "Restate.client with static headers" do
it "passes a plain Hash through to the Client" do
Restate.configure do |c|
c.ingress_headers = { "Authorization" => "Bearer tok" }
c.admin_headers = { "X-Admin" => "yes" }
end

client = Restate.client

# Verify via introspection that the Client received the resolved hashes
expect(client.instance_variable_get(:@ingress_headers)).to eq({ "Authorization" => "Bearer tok" })
expect(client.instance_variable_get(:@admin_headers)).to eq({ "X-Admin" => "yes" })
end
end

describe "Restate.client with callable headers" do
it "invokes a lambda and passes its return value to the Client" do
call_count = 0
Restate.configure do |c|
c.ingress_headers = -> {
call_count += 1
{ "X-Request-Id" => "req-#{call_count}" }
}
end

client1 = Restate.client
client2 = Restate.client

expect(call_count).to eq(2)
expect(client1.instance_variable_get(:@ingress_headers)).to eq({ "X-Request-Id" => "req-1" })
expect(client2.instance_variable_get(:@ingress_headers)).to eq({ "X-Request-Id" => "req-2" })
end

it "evaluates a Proc fresh on every Restate.client call" do
counter = 0
Restate.configure do |c|
c.admin_headers = proc {
counter += 1
{ "X-Counter" => counter.to_s }
}
end

clients = 5.times.map { Restate.client }

expect(counter).to eq(5)
clients.each_with_index do |client, i|
expect(client.instance_variable_get(:@admin_headers)).to eq({ "X-Counter" => (i + 1).to_s })
end
end

it "supports callable for admin_headers and static for ingress_headers simultaneously" do
Restate.configure do |c|
c.ingress_headers = { "Static" => "header" }
c.admin_headers = -> { { "Dynamic" => "admin" } }
end

client = Restate.client
expect(client.instance_variable_get(:@ingress_headers)).to eq({ "Static" => "header" })
expect(client.instance_variable_get(:@admin_headers)).to eq({ "Dynamic" => "admin" })
end
end
end
4 changes: 3 additions & 1 deletion spec/introspection_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ def config

def client
cfg = config
resolve = ->(h) { h.respond_to?(:call) ? h.call : h }
Client.new(ingress_url: cfg.ingress_url, admin_url: cfg.admin_url,
ingress_headers: cfg.ingress_headers, admin_headers: cfg.admin_headers)
ingress_headers: resolve.call(cfg.ingress_headers),
admin_headers: resolve.call(cfg.admin_headers))
end
end
end
Expand Down
Loading