Class: SignalWire::AgentServer
- Inherits:
-
Object
- Object
- SignalWire::AgentServer
- Defined in:
- lib/signalwire/server/agent_server.rb
Overview
Multi-agent hosting on a single Rack application.
server = AgentServer.new(host: '0.0.0.0', port: 3000)
server.register(my_agent, route: '/agent1')
server.register(my_agent2, route: '/agent2')
server.run
Constant Summary collapse
- MIME_TYPES =
MIME types for static file serving.
{ '.html' => 'text/html', '.htm' => 'text/html', '.css' => 'text/css', '.js' => 'application/javascript', '.json' => 'application/json', '.png' => 'image/png', '.jpg' => 'image/jpeg', '.jpeg' => 'image/jpeg', '.gif' => 'image/gif', '.svg' => 'image/svg+xml', '.ico' => 'image/x-icon', '.txt' => 'text/plain', '.xml' => 'application/xml', '.woff' => 'font/woff', '.woff2' => 'font/woff2', '.ttf' => 'font/ttf', '.eot' => 'application/vnd.ms-fontobject', '.map' => 'application/json', '.webp' => 'image/webp', '.pdf' => 'application/pdf' }.freeze
- STATIC_SECURITY_HEADERS =
Security headers applied to static file responses.
{ 'x-content-type-options' => 'nosniff', 'x-frame-options' => 'DENY', 'cache-control' => 'no-store, no-cache, must-revalidate' }.freeze
Instance Attribute Summary collapse
-
#host ⇒ Object
readonly
Returns the value of attribute host.
-
#log_level ⇒ Object
readonly
Returns the value of attribute log_level.
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#port ⇒ Object
readonly
Returns the value of attribute port.
Instance Method Summary collapse
-
#_apply_log_level(logger, level) ⇒ Object
private
Map a Python-style log level string to the underlying logger’s threshold.
- #_detect_execution_mode ⇒ Object private
-
#_handle_cgi_request ⇒ Object
private
Handle a CGI request — minimal Ruby parity for Python’s “_handle_cgi_request“.
-
#_handle_lambda_request(event, _context) ⇒ Object
private
Handle a Lambda invocation event.
- #_run_server(host = nil, port = nil) ⇒ Object private
-
#_try_serve_static(path, static_routes) ⇒ Object
private
Attempt to serve a static file.
-
#app ⇒ Object
Public Rack application — Python parity: “server.app“ exposes the underlying FastAPI instance.
-
#get_agent(route) ⇒ Object?
Get a specific agent by route.
-
#get_agents ⇒ Hash
Get all registered agents.
-
#initialize(host: '0.0.0.0', port: 3000, log_level: 'info') ⇒ AgentServer
constructor
Construct an AgentServer.
-
#rack_app ⇒ Proc
Build a Rack application that routes requests to the appropriate agent.
-
#register(agent, route: nil) ⇒ Object
Register an agent at a given route.
-
#register_sip_username(username, route) ⇒ Object
Register a SIP username mapping to a route.
-
#run(event: nil, context: nil, host: nil, port: nil) ⇒ Object?
Universal run method — mirrors Python’s “AgentServer.run(event=None, context=None, host=None, port=None)“.
-
#serve_static_files(directory, route) ⇒ self
Serve static files from a directory at a given route.
-
#setup_sip_routing(route: '/sip', auto_map: true) ⇒ Object
Set up SIP-based routing.
-
#unregister(route) ⇒ Object?
Unregister an agent by route.
Constructor Details
#initialize(host: '0.0.0.0', port: 3000, log_level: 'info') ⇒ AgentServer
Construct an AgentServer.
Python parity: “AgentServer(host, port, log_level)“ —“log_level“ controls the AgentServer’s logger verbosity. The Ruby port maps it through “SignalWire::Logging.logger“ so the WARN/INFO/DEBUG semantics match Python’s “logging“ levels.
74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/signalwire/server/agent_server.rb', line 74 def initialize(host: '0.0.0.0', port: 3000, log_level: 'info') @host = host @port = port @log_level = log_level.to_s.downcase @agents = {} # route => agent object @sip_routes = {} # username => route @static_routes = {} # route => directory @mutex = Mutex.new @logger = Logging.logger("AgentServer") _apply_log_level(@logger, @log_level) end |
Instance Attribute Details
#host ⇒ Object (readonly)
Returns the value of attribute host.
21 22 23 |
# File 'lib/signalwire/server/agent_server.rb', line 21 def host @host end |
#log_level ⇒ Object (readonly)
Returns the value of attribute log_level.
21 22 23 |
# File 'lib/signalwire/server/agent_server.rb', line 21 def log_level @log_level end |
#logger ⇒ Object (readonly)
Returns the value of attribute logger.
21 22 23 |
# File 'lib/signalwire/server/agent_server.rb', line 21 def logger @logger end |
#port ⇒ Object (readonly)
Returns the value of attribute port.
21 22 23 |
# File 'lib/signalwire/server/agent_server.rb', line 21 def port @port end |
Instance Method Details
#_apply_log_level(logger, level) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Map a Python-style log level string to the underlying logger’s threshold. Mirrors Python’s “log_level“ mapping in AgentServer so callers get equivalent verbosity controls.
The SignalWire stdlib logger doesn’t expose a per-instance “level=“; we attach a “@level“ ivar to the underlying “Logger“ so introspection-style tests can check it. The “::Logger“ constant from Ruby’s stdlib (“require ‘logger’“) exposes DEBUG/INFO/WARN/ERROR/FATAL constants we mirror.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
# File 'lib/signalwire/server/agent_server.rb', line 97 def _apply_log_level(logger, level) require 'logger' mapped = case level when 'debug' then ::Logger::DEBUG when 'info' then ::Logger::INFO when 'warning', 'warn' then ::Logger::WARN when 'error' then ::Logger::ERROR when 'critical', 'fatal' then ::Logger::FATAL else ::Logger::INFO end if logger.respond_to?(:level=) logger.level = mapped else # SignalWire::Logging::Logger doesn't expose level=; attach # via instance_variable so .level reads return the mapped # value. We add a singleton accessor. logger.instance_variable_set(:@level, mapped) unless logger.respond_to?(:level) logger.define_singleton_method(:level) { @level } end end mapped rescue StandardError ::Logger::INFO rescue nil end |
#_detect_execution_mode ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
230 231 232 233 234 |
# File 'lib/signalwire/server/agent_server.rb', line 230 def _detect_execution_mode return 'lambda' if ENV['AWS_LAMBDA_FUNCTION_NAME'] && !ENV['AWS_LAMBDA_FUNCTION_NAME'].empty? return 'cgi' if ENV['GATEWAY_INTERFACE'] 'server' end |
#_handle_cgi_request ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Handle a CGI request — minimal Ruby parity for Python’s “_handle_cgi_request“. Reads “PATH_INFO“, dispatches to the matching agent, and returns a CGI-formatted response string.
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/signalwire/server/agent_server.rb', line 259 def _handle_cgi_request require 'stringio' path_info = (ENV['PATH_INFO'] || '').strip env = { 'PATH_INFO' => path_info, 'REQUEST_METHOD' => ENV['REQUEST_METHOD'] || 'GET', 'QUERY_STRING' => ENV['QUERY_STRING'] || '', 'rack.input' => StringIO.new(''), 'rack.errors' => $stderr } status, headers, body = rack_app.call(env) body_str = body.respond_to?(:join) ? body.join : body.to_s out = +"Status: #{status}\r\n" headers.each { |k, v| out << "#{k}: #{v}\r\n" } out << "\r\n" out << body_str out end |
#_handle_lambda_request(event, _context) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Handle a Lambda invocation event. Translates the Lambda event shape into a Rack env, dispatches, and returns a Lambda response Hash (statusCode/headers/body).
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/signalwire/server/agent_server.rb', line 283 def _handle_lambda_request(event, _context) require 'stringio' event ||= {} path = event['path'] || event['rawPath'] || event['pathParameters']&.dig('proxy') || '/' method = event['httpMethod'] || event.dig('requestContext', 'http', 'method') || 'GET' body = event['body'] || '' env = { 'PATH_INFO' => path, 'REQUEST_METHOD' => method, 'QUERY_STRING' => '', 'rack.input' => StringIO.new(body), 'rack.errors' => $stderr } status, headers, response_body = rack_app.call(env) body_str = response_body.respond_to?(:join) ? response_body.join : response_body.to_s { 'statusCode' => Integer(status), 'headers' => headers, 'body' => body_str } end |
#_run_server(host = nil, port = nil) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/signalwire/server/agent_server.rb', line 237 def _run_server(host = nil, port = nil) bind_host = host || @host bind_port = port || @port app = rack_app require 'webrick' server = WEBrick::HTTPServer.new( Host: bind_host, Port: bind_port, Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN), AccessLog: [] ) server.mount('/', Rack::Handler::WEBrick, app) if defined?(Rack::Handler::WEBrick) trap('INT') { server.shutdown } trap('TERM') { server.shutdown } @logger&.info("AgentServer starting on #{bind_host}:#{bind_port}") server.start end |
#_try_serve_static(path, static_routes) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Attempt to serve a static file. Returns a Rack response or nil.
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 |
# File 'lib/signalwire/server/agent_server.rb', line 368 def _try_serve_static(path, static_routes) matched_route = nil matched_dir = nil static_routes.each do |route, directory| if path == route || path.start_with?("#{route}/") if matched_route.nil? || route.length > matched_route.length matched_route = route matched_dir = directory end end end return nil unless matched_dir # Extract the relative path after the route prefix relative = path.sub(matched_route, '') relative = '/index.html' if relative.empty? || relative == '/' # Path traversal protection: reject any path containing ".." if relative.include?('..') body = JSON.generate({ error: 'Forbidden' }) return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]] end file_path = File.join(matched_dir, relative) resolved = File.(file_path) # Ensure resolved path is still under the served directory unless resolved.start_with?(matched_dir + '/') || resolved == matched_dir body = JSON.generate({ error: 'Forbidden' }) return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]] end if File.file?(resolved) && File.readable?(resolved) ext = File.extname(resolved).downcase content_type = MIME_TYPES[ext] || 'application/octet-stream' content = File.binread(resolved) headers = STATIC_SECURITY_HEADERS.merge('Content-Type' => content_type, 'Content-Length' => content.bytesize.to_s) ['200', headers, [content]] else nil end end |
#app ⇒ Object
Public Rack application — Python parity: “server.app“ exposes the underlying FastAPI instance. Ruby exposes the cached Rack app (a Proc) so callers can mount it on their own server or pass it to Rack-compatible test harnesses.
27 28 29 |
# File 'lib/signalwire/server/agent_server.rb', line 27 def app @rack_app ||= rack_app end |
#get_agent(route) ⇒ Object?
Get a specific agent by route.
154 155 156 157 |
# File 'lib/signalwire/server/agent_server.rb', line 154 def get_agent(route) route = "/#{route}" unless route.start_with?('/') @mutex.synchronize { @agents[route] } end |
#get_agents ⇒ Hash
Get all registered agents.
147 148 149 |
# File 'lib/signalwire/server/agent_server.rb', line 147 def get_agents @mutex.synchronize { @agents.dup } end |
#rack_app ⇒ Proc
Build a Rack application that routes requests to the appropriate agent.
307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 |
# File 'lib/signalwire/server/agent_server.rb', line 307 def rack_app agents = @agents sip_routes = @sip_routes static_routes = @static_routes server = self Proc.new do |env| path = env['PATH_INFO'] || '/' case path when '/health', '/healthz' body = { status: 'ok', agents: agents.keys }.to_json ['200', { 'Content-Type' => 'application/json' }, [body]] when '/' body = { service: 'SignalWire Agent Server', agents: agents.keys, version: defined?(SignalWire::VERSION) ? SignalWire::VERSION : '1.0.0' }.to_json ['200', { 'Content-Type' => 'application/json' }, [body]] else # Check static routes first (longest prefix match) static_result = server._try_serve_static(path, static_routes) if static_result static_result else # Find the matching agent by longest prefix match agent = nil matched_route = nil agents.each do |route, a| if path == route || path.start_with?("#{route}/") if matched_route.nil? || route.length > matched_route.length matched_route = route agent = a end end end if agent if agent.respond_to?(:call) agent.call(env) elsif agent.respond_to?(:rack_app) agent.rack_app.call(env) else body = { agent: matched_route, status: 'registered' }.to_json ['200', { 'Content-Type' => 'application/json' }, [body]] end else body = { error: 'Not found', path: path }.to_json ['404', { 'Content-Type' => 'application/json' }, [body]] end end end end end |
#register(agent, route: nil) ⇒ Object
Register an agent at a given route.
126 127 128 129 130 131 132 133 134 135 |
# File 'lib/signalwire/server/agent_server.rb', line 126 def register(agent, route: nil) route ||= agent.respond_to?(:route) ? agent.route : "/#{agent.object_id}" route = "/#{route}" unless route.start_with?('/') @mutex.synchronize do raise ArgumentError, "Route already registered: #{route}" if @agents.key?(route) @agents[route] = agent end self end |
#register_sip_username(username, route) ⇒ Object
Register a SIP username mapping to a route.
176 177 178 179 180 |
# File 'lib/signalwire/server/agent_server.rb', line 176 def register_sip_username(username, route) route = "/#{route}" unless route.start_with?('/') @mutex.synchronize { @sip_routes[username] = route } self end |
#run(event: nil, context: nil, host: nil, port: nil) ⇒ Object?
Universal run method — mirrors Python’s “AgentServer.run(event=None, context=None, host=None, port=None)“.
Detects execution mode and routes appropriately:
-
**Server mode** — starts WEBrick (Ruby’s stdlib HTTP server) bound to “host“/“port“ (overrides honoured if supplied).
-
**Lambda mode** (“AWS_LAMBDA_FUNCTION_NAME“ env var present) — invokes “_handle_lambda_request(event, context)“ and returns the Lambda response Hash.
-
**CGI mode** (“GATEWAY_INTERFACE“ env var present) — invokes “_handle_cgi_request“ and returns the CGI response String.
216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/signalwire/server/agent_server.rb', line 216 def run(event: nil, context: nil, host: nil, port: nil) mode = _detect_execution_mode case mode when 'lambda' _handle_lambda_request(event, context) when 'cgi' _handle_cgi_request else _run_server(host, port) end end |
#serve_static_files(directory, route) ⇒ self
Serve static files from a directory at a given route.
187 188 189 190 191 192 193 194 195 |
# File 'lib/signalwire/server/agent_server.rb', line 187 def serve_static_files(directory, route) route = "/#{route}" unless route.start_with?('/') route = route.chomp('/') resolved = File.(directory) raise ArgumentError, "Directory does not exist: #{resolved}" unless File.directory?(resolved) @mutex.synchronize { @static_routes[route] = resolved } self end |
#setup_sip_routing(route: '/sip', auto_map: true) ⇒ Object
Set up SIP-based routing.
162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/signalwire/server/agent_server.rb', line 162 def setup_sip_routing(route: '/sip', auto_map: true) @sip_route = route if auto_map @mutex.synchronize do @agents.each do |r, agent| username = r.sub(%r{^/}, '').tr('/', '_') @sip_routes[username] = r end end end self end |
#unregister(route) ⇒ Object?
Unregister an agent by route.
140 141 142 143 |
# File 'lib/signalwire/server/agent_server.rb', line 140 def unregister(route) route = "/#{route}" unless route.start_with?('/') @mutex.synchronize { @agents.delete(route) } end |