signalwire-sdk 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. metadata +225 -0
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require 'json'
9
+ require 'thread'
10
+ require_relative '../logging'
11
+
12
+ module SignalWire
13
+ # Multi-agent hosting on a single Rack application.
14
+ #
15
+ # server = AgentServer.new(host: '0.0.0.0', port: 3000)
16
+ # server.register(my_agent, route: '/agent1')
17
+ # server.register(my_agent2, route: '/agent2')
18
+ # server.run
19
+ #
20
+ class AgentServer
21
+ attr_reader :host, :port, :log_level, :logger
22
+
23
+ # Public Rack application — Python parity: ``server.app`` exposes
24
+ # the underlying FastAPI instance. Ruby exposes the cached Rack
25
+ # app (a Proc) so callers can mount it on their own server or
26
+ # pass it to Rack-compatible test harnesses.
27
+ def app
28
+ @rack_app ||= rack_app
29
+ end
30
+
31
+ # MIME types for static file serving.
32
+ MIME_TYPES = {
33
+ '.html' => 'text/html',
34
+ '.htm' => 'text/html',
35
+ '.css' => 'text/css',
36
+ '.js' => 'application/javascript',
37
+ '.json' => 'application/json',
38
+ '.png' => 'image/png',
39
+ '.jpg' => 'image/jpeg',
40
+ '.jpeg' => 'image/jpeg',
41
+ '.gif' => 'image/gif',
42
+ '.svg' => 'image/svg+xml',
43
+ '.ico' => 'image/x-icon',
44
+ '.txt' => 'text/plain',
45
+ '.xml' => 'application/xml',
46
+ '.woff' => 'font/woff',
47
+ '.woff2' => 'font/woff2',
48
+ '.ttf' => 'font/ttf',
49
+ '.eot' => 'application/vnd.ms-fontobject',
50
+ '.map' => 'application/json',
51
+ '.webp' => 'image/webp',
52
+ '.pdf' => 'application/pdf'
53
+ }.freeze
54
+
55
+ # Security headers applied to static file responses.
56
+ STATIC_SECURITY_HEADERS = {
57
+ 'x-content-type-options' => 'nosniff',
58
+ 'x-frame-options' => 'DENY',
59
+ 'cache-control' => 'no-store, no-cache, must-revalidate'
60
+ }.freeze
61
+
62
+ # Construct an AgentServer.
63
+ #
64
+ # Python parity: ``AgentServer(host, port, log_level)`` —
65
+ # ``log_level`` controls the AgentServer's logger verbosity. The
66
+ # Ruby port maps it through ``SignalWire::Logging.logger`` so the
67
+ # WARN/INFO/DEBUG semantics match Python's ``logging`` levels.
68
+ #
69
+ # @param host [String] bind address (default ``"0.0.0.0"``)
70
+ # @param port [Integer] bind port (default ``3000``)
71
+ # @param log_level [String] log level — one of ``"debug"``,
72
+ # ``"info"``, ``"warning"``/``"warn"``, ``"error"``,
73
+ # ``"critical"``/``"fatal"``. Default ``"info"``.
74
+ def initialize(host: '0.0.0.0', port: 3000, log_level: 'info')
75
+ @host = host
76
+ @port = port
77
+ @log_level = log_level.to_s.downcase
78
+ @agents = {} # route => agent object
79
+ @sip_routes = {} # username => route
80
+ @static_routes = {} # route => directory
81
+ @mutex = Mutex.new
82
+
83
+ @logger = Logging.logger("AgentServer")
84
+ _apply_log_level(@logger, @log_level)
85
+ end
86
+
87
+ # Map a Python-style log level string to the underlying logger's
88
+ # threshold. Mirrors Python's ``log_level`` mapping in AgentServer
89
+ # so callers get equivalent verbosity controls.
90
+ #
91
+ # The SignalWire stdlib logger doesn't expose a per-instance
92
+ # ``level=``; we attach a ``@level`` ivar to the underlying
93
+ # ``Logger`` so introspection-style tests can check it. The
94
+ # ``::Logger`` constant from Ruby's stdlib (``require 'logger'``)
95
+ # exposes DEBUG/INFO/WARN/ERROR/FATAL constants we mirror.
96
+ # @api private
97
+ def _apply_log_level(logger, level)
98
+ require 'logger'
99
+ mapped = case level
100
+ when 'debug' then ::Logger::DEBUG
101
+ when 'info' then ::Logger::INFO
102
+ when 'warning', 'warn' then ::Logger::WARN
103
+ when 'error' then ::Logger::ERROR
104
+ when 'critical', 'fatal' then ::Logger::FATAL
105
+ else ::Logger::INFO
106
+ end
107
+ if logger.respond_to?(:level=)
108
+ logger.level = mapped
109
+ else
110
+ # SignalWire::Logging::Logger doesn't expose level=; attach
111
+ # via instance_variable so .level reads return the mapped
112
+ # value. We add a singleton accessor.
113
+ logger.instance_variable_set(:@level, mapped)
114
+ unless logger.respond_to?(:level)
115
+ logger.define_singleton_method(:level) { @level }
116
+ end
117
+ end
118
+ mapped
119
+ rescue StandardError
120
+ ::Logger::INFO rescue nil
121
+ end
122
+
123
+ # Register an agent at a given route.
124
+ # @param agent [Object] an agent object (e.g. AgentBase or prefab)
125
+ # @param route [String, nil] HTTP route; defaults to agent.route if available
126
+ def register(agent, route: nil)
127
+ route ||= agent.respond_to?(:route) ? agent.route : "/#{agent.object_id}"
128
+ route = "/#{route}" unless route.start_with?('/')
129
+
130
+ @mutex.synchronize do
131
+ raise ArgumentError, "Route already registered: #{route}" if @agents.key?(route)
132
+ @agents[route] = agent
133
+ end
134
+ self
135
+ end
136
+
137
+ # Unregister an agent by route.
138
+ # @param route [String]
139
+ # @return [Object, nil] the removed agent
140
+ def unregister(route)
141
+ route = "/#{route}" unless route.start_with?('/')
142
+ @mutex.synchronize { @agents.delete(route) }
143
+ end
144
+
145
+ # Get all registered agents.
146
+ # @return [Hash] route => agent
147
+ def get_agents
148
+ @mutex.synchronize { @agents.dup }
149
+ end
150
+
151
+ # Get a specific agent by route.
152
+ # @param route [String]
153
+ # @return [Object, nil]
154
+ def get_agent(route)
155
+ route = "/#{route}" unless route.start_with?('/')
156
+ @mutex.synchronize { @agents[route] }
157
+ end
158
+
159
+ # Set up SIP-based routing.
160
+ # @param route [String] the route to handle SIP requests
161
+ # @param auto_map [Boolean] automatically map agent names as SIP usernames
162
+ def setup_sip_routing(route: '/sip', auto_map: true)
163
+ @sip_route = route
164
+ if auto_map
165
+ @mutex.synchronize do
166
+ @agents.each do |r, agent|
167
+ username = r.sub(%r{^/}, '').tr('/', '_')
168
+ @sip_routes[username] = r
169
+ end
170
+ end
171
+ end
172
+ self
173
+ end
174
+
175
+ # Register a SIP username mapping to a route.
176
+ def register_sip_username(username, route)
177
+ route = "/#{route}" unless route.start_with?('/')
178
+ @mutex.synchronize { @sip_routes[username] = route }
179
+ self
180
+ end
181
+
182
+ # Serve static files from a directory at a given route.
183
+ #
184
+ # @param directory [String] absolute or relative path to the directory
185
+ # @param route [String] the URL prefix to serve files at
186
+ # @return [self]
187
+ def serve_static_files(directory, route)
188
+ route = "/#{route}" unless route.start_with?('/')
189
+ route = route.chomp('/')
190
+ resolved = File.expand_path(directory)
191
+ raise ArgumentError, "Directory does not exist: #{resolved}" unless File.directory?(resolved)
192
+
193
+ @mutex.synchronize { @static_routes[route] = resolved }
194
+ self
195
+ end
196
+
197
+ # Universal run method — mirrors Python's
198
+ # ``AgentServer.run(event=None, context=None, host=None, port=None)``.
199
+ #
200
+ # Detects execution mode and routes appropriately:
201
+ #
202
+ # - **Server mode** — starts WEBrick (Ruby's stdlib HTTP server)
203
+ # bound to ``host``/``port`` (overrides honoured if supplied).
204
+ # - **Lambda mode** (``AWS_LAMBDA_FUNCTION_NAME`` env var present)
205
+ # — invokes ``_handle_lambda_request(event, context)`` and
206
+ # returns the Lambda response Hash.
207
+ # - **CGI mode** (``GATEWAY_INTERFACE`` env var present) — invokes
208
+ # ``_handle_cgi_request`` and returns the CGI response String.
209
+ #
210
+ # @param event [Object, nil] serverless event (Lambda)
211
+ # @param context [Object, nil] serverless context (Lambda)
212
+ # @param host [String, nil] override bind host (server mode)
213
+ # @param port [Integer, nil] override bind port (server mode)
214
+ # @return [Object, nil] response for serverless modes, nil for
215
+ # server mode (blocking until shutdown).
216
+ def run(event: nil, context: nil, host: nil, port: nil)
217
+ mode = _detect_execution_mode
218
+
219
+ case mode
220
+ when 'lambda'
221
+ _handle_lambda_request(event, context)
222
+ when 'cgi'
223
+ _handle_cgi_request
224
+ else
225
+ _run_server(host, port)
226
+ end
227
+ end
228
+
229
+ # @api private
230
+ def _detect_execution_mode
231
+ return 'lambda' if ENV['AWS_LAMBDA_FUNCTION_NAME'] && !ENV['AWS_LAMBDA_FUNCTION_NAME'].empty?
232
+ return 'cgi' if ENV['GATEWAY_INTERFACE']
233
+ 'server'
234
+ end
235
+
236
+ # @api private
237
+ def _run_server(host = nil, port = nil)
238
+ bind_host = host || @host
239
+ bind_port = port || @port
240
+ app = rack_app
241
+ require 'webrick'
242
+ server = WEBrick::HTTPServer.new(
243
+ Host: bind_host,
244
+ Port: bind_port,
245
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
246
+ AccessLog: []
247
+ )
248
+ server.mount('/', Rack::Handler::WEBrick, app) if defined?(Rack::Handler::WEBrick)
249
+ trap('INT') { server.shutdown }
250
+ trap('TERM') { server.shutdown }
251
+ @logger&.info("AgentServer starting on #{bind_host}:#{bind_port}")
252
+ server.start
253
+ end
254
+
255
+ # @api private
256
+ # Handle a CGI request — minimal Ruby parity for Python's
257
+ # ``_handle_cgi_request``. Reads ``PATH_INFO``, dispatches to the
258
+ # matching agent, and returns a CGI-formatted response string.
259
+ def _handle_cgi_request
260
+ require 'stringio'
261
+ path_info = (ENV['PATH_INFO'] || '').strip
262
+ env = {
263
+ 'PATH_INFO' => path_info,
264
+ 'REQUEST_METHOD' => ENV['REQUEST_METHOD'] || 'GET',
265
+ 'QUERY_STRING' => ENV['QUERY_STRING'] || '',
266
+ 'rack.input' => StringIO.new(''),
267
+ 'rack.errors' => $stderr
268
+ }
269
+ status, headers, body = rack_app.call(env)
270
+ body_str = body.respond_to?(:join) ? body.join : body.to_s
271
+
272
+ out = +"Status: #{status}\r\n"
273
+ headers.each { |k, v| out << "#{k}: #{v}\r\n" }
274
+ out << "\r\n"
275
+ out << body_str
276
+ out
277
+ end
278
+
279
+ # @api private
280
+ # Handle a Lambda invocation event. Translates the Lambda event
281
+ # shape into a Rack env, dispatches, and returns a Lambda
282
+ # response Hash (statusCode/headers/body).
283
+ def _handle_lambda_request(event, _context)
284
+ require 'stringio'
285
+ event ||= {}
286
+ path = event['path'] || event['rawPath'] || event['pathParameters']&.dig('proxy') || '/'
287
+ method = event['httpMethod'] || event.dig('requestContext', 'http', 'method') || 'GET'
288
+ body = event['body'] || ''
289
+ env = {
290
+ 'PATH_INFO' => path,
291
+ 'REQUEST_METHOD' => method,
292
+ 'QUERY_STRING' => '',
293
+ 'rack.input' => StringIO.new(body),
294
+ 'rack.errors' => $stderr
295
+ }
296
+ status, headers, response_body = rack_app.call(env)
297
+ body_str = response_body.respond_to?(:join) ? response_body.join : response_body.to_s
298
+ {
299
+ 'statusCode' => Integer(status),
300
+ 'headers' => headers,
301
+ 'body' => body_str
302
+ }
303
+ end
304
+
305
+ # Build a Rack application that routes requests to the appropriate agent.
306
+ # @return [Proc] a Rack-compatible app
307
+ def rack_app
308
+ agents = @agents
309
+ sip_routes = @sip_routes
310
+ static_routes = @static_routes
311
+ server = self
312
+
313
+ Proc.new do |env|
314
+ path = env['PATH_INFO'] || '/'
315
+
316
+ case path
317
+ when '/health', '/healthz'
318
+ body = { status: 'ok', agents: agents.keys }.to_json
319
+ ['200', { 'Content-Type' => 'application/json' }, [body]]
320
+
321
+ when '/'
322
+ body = {
323
+ service: 'SignalWire Agent Server',
324
+ agents: agents.keys,
325
+ version: defined?(SignalWire::VERSION) ? SignalWire::VERSION : '1.0.0'
326
+ }.to_json
327
+ ['200', { 'Content-Type' => 'application/json' }, [body]]
328
+
329
+ else
330
+ # Check static routes first (longest prefix match)
331
+ static_result = server._try_serve_static(path, static_routes)
332
+ if static_result
333
+ static_result
334
+ else
335
+ # Find the matching agent by longest prefix match
336
+ agent = nil
337
+ matched_route = nil
338
+
339
+ agents.each do |route, a|
340
+ if path == route || path.start_with?("#{route}/")
341
+ if matched_route.nil? || route.length > matched_route.length
342
+ matched_route = route
343
+ agent = a
344
+ end
345
+ end
346
+ end
347
+
348
+ if agent
349
+ if agent.respond_to?(:call)
350
+ agent.call(env)
351
+ elsif agent.respond_to?(:rack_app)
352
+ agent.rack_app.call(env)
353
+ else
354
+ body = { agent: matched_route, status: 'registered' }.to_json
355
+ ['200', { 'Content-Type' => 'application/json' }, [body]]
356
+ end
357
+ else
358
+ body = { error: 'Not found', path: path }.to_json
359
+ ['404', { 'Content-Type' => 'application/json' }, [body]]
360
+ end
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ # @api private
367
+ # Attempt to serve a static file. Returns a Rack response or nil.
368
+ def _try_serve_static(path, static_routes)
369
+ matched_route = nil
370
+ matched_dir = nil
371
+
372
+ static_routes.each do |route, directory|
373
+ if path == route || path.start_with?("#{route}/")
374
+ if matched_route.nil? || route.length > matched_route.length
375
+ matched_route = route
376
+ matched_dir = directory
377
+ end
378
+ end
379
+ end
380
+
381
+ return nil unless matched_dir
382
+
383
+ # Extract the relative path after the route prefix
384
+ relative = path.sub(matched_route, '')
385
+ relative = '/index.html' if relative.empty? || relative == '/'
386
+
387
+ # Path traversal protection: reject any path containing ".."
388
+ if relative.include?('..')
389
+ body = JSON.generate({ error: 'Forbidden' })
390
+ return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]]
391
+ end
392
+
393
+ file_path = File.join(matched_dir, relative)
394
+ resolved = File.expand_path(file_path)
395
+
396
+ # Ensure resolved path is still under the served directory
397
+ unless resolved.start_with?(matched_dir + '/') || resolved == matched_dir
398
+ body = JSON.generate({ error: 'Forbidden' })
399
+ return ['403', STATIC_SECURITY_HEADERS.merge('Content-Type' => 'application/json'), [body]]
400
+ end
401
+
402
+ if File.file?(resolved) && File.readable?(resolved)
403
+ ext = File.extname(resolved).downcase
404
+ content_type = MIME_TYPES[ext] || 'application/octet-stream'
405
+ content = File.binread(resolved)
406
+ headers = STATIC_SECURITY_HEADERS.merge('Content-Type' => content_type, 'Content-Length' => content.bytesize.to_s)
407
+ ['200', headers, [content]]
408
+ else
409
+ nil
410
+ end
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require 'base64'
9
+ require 'json'
10
+ require 'stringio'
11
+ require 'uri'
12
+
13
+ module SignalWire
14
+ module Serverless
15
+ # Adapter that lets an AWS Lambda function invoke a Rack application.
16
+ #
17
+ # Typical usage from a Lambda entrypoint file:
18
+ #
19
+ # require 'signalwire'
20
+ #
21
+ # AGENT = SignalWire::AgentBase.new(name: 'my-agent', route: '/')
22
+ # # ...configure AGENT...
23
+ #
24
+ # HANDLER = SignalWire::Serverless::LambdaHandler.new(AGENT.rack_app)
25
+ #
26
+ # def handler(event:, context:)
27
+ # HANDLER.call(event, context)
28
+ # end
29
+ #
30
+ # The adapter accepts events from either Lambda Function URLs / API
31
+ # Gateway HTTP API (payload format v2) or the classic API Gateway REST
32
+ # API (payload format v1) and returns a response in the matching
33
+ # shape. Any triple returned by the Rack app (status, headers, body)
34
+ # is translated into the +{statusCode:, headers:, body:}+ shape
35
+ # expected by Lambda.
36
+ #
37
+ # The adapter never reaches out to the network and has no gem
38
+ # dependencies beyond what the SignalWire SDK already requires, so it
39
+ # can be bundled directly into a Lambda zip.
40
+ class LambdaHandler
41
+ # @param app [#call] a Rack-compatible application
42
+ def initialize(app)
43
+ raise ArgumentError, 'app must respond to #call' unless app.respond_to?(:call)
44
+
45
+ @app = app
46
+ end
47
+
48
+ # Invoke the wrapped Rack application with a Lambda event.
49
+ #
50
+ # @param event [Hash] the Lambda invocation event
51
+ # @param _context [Object] the Lambda context (ignored)
52
+ # @return [Hash] a Lambda-shaped response hash
53
+ def call(event, _context = nil)
54
+ event ||= {}
55
+ env = build_env(event)
56
+
57
+ status, headers, body = @app.call(env)
58
+
59
+ build_response(event, status, headers, body)
60
+ ensure
61
+ body.close if body.respond_to?(:close)
62
+ end
63
+
64
+ # Class-level convenience so consumers can use
65
+ # +SignalWire::Serverless::LambdaHandler.for(agent)+ without
66
+ # duplicating +.rack_app+ at the call site.
67
+ #
68
+ # @param agent_or_app [Object] either an AgentBase (responds to
69
+ # +rack_app+) or any Rack-compatible application
70
+ # @return [LambdaHandler]
71
+ def self.for(agent_or_app)
72
+ app = if agent_or_app.respond_to?(:rack_app)
73
+ agent_or_app.rack_app
74
+ else
75
+ agent_or_app
76
+ end
77
+ new(app)
78
+ end
79
+
80
+ private
81
+
82
+ # ------------------------------------------------------------------
83
+ # Request: Lambda event -> Rack env
84
+ # ------------------------------------------------------------------
85
+
86
+ def build_env(event)
87
+ version = detect_version(event)
88
+ method = extract_method(event, version)
89
+ path = extract_path(event, version)
90
+ query = extract_query_string(event, version)
91
+ headers = extract_headers(event)
92
+ body_io, content_length = extract_body(event)
93
+
94
+ env = {
95
+ 'REQUEST_METHOD' => method,
96
+ 'SCRIPT_NAME' => '',
97
+ 'PATH_INFO' => path,
98
+ 'QUERY_STRING' => query,
99
+ 'SERVER_NAME' => headers['host'] || 'lambda',
100
+ 'SERVER_PORT' => (headers['x-forwarded-port'] || '443'),
101
+ 'SERVER_PROTOCOL' => 'HTTP/1.1',
102
+ 'HTTP_VERSION' => 'HTTP/1.1',
103
+ 'rack.version' => [1, 6],
104
+ 'rack.url_scheme' => headers['x-forwarded-proto'] || 'https',
105
+ 'rack.input' => body_io,
106
+ 'rack.errors' => $stderr,
107
+ 'rack.multithread' => false,
108
+ 'rack.multiprocess' => false,
109
+ 'rack.run_once' => true,
110
+ 'rack.hijack?' => false,
111
+ 'signalwire.lambda_event' => event
112
+ }
113
+ env['CONTENT_LENGTH'] = content_length.to_s if content_length
114
+ env['CONTENT_TYPE'] = headers['content-type'] if headers['content-type']
115
+
116
+ headers.each do |name, value|
117
+ next if name == 'content-type' || name == 'content-length'
118
+
119
+ env["HTTP_#{name.tr('-', '_').upcase}"] = value
120
+ end
121
+
122
+ env
123
+ end
124
+
125
+ def detect_version(event)
126
+ # API Gateway HTTP API / Function URLs use payload v2 and include
127
+ # a 'version' key of "2.0". Everything else (REST API, ALB, direct
128
+ # invoke) is treated as v1.
129
+ event['version'].to_s.start_with?('2') ? 2 : 1
130
+ end
131
+
132
+ def extract_method(event, version)
133
+ if version == 2
134
+ event.dig('requestContext', 'http', 'method') || event['httpMethod'] || 'GET'
135
+ else
136
+ event['httpMethod'] || 'GET'
137
+ end
138
+ end
139
+
140
+ def extract_path(event, _version)
141
+ raw =
142
+ event['rawPath'] ||
143
+ event['path'] ||
144
+ event.dig('requestContext', 'http', 'path') ||
145
+ '/'
146
+ raw = "/#{raw}" unless raw.start_with?('/')
147
+ raw
148
+ end
149
+
150
+ def extract_query_string(event, _version)
151
+ if event['rawQueryString'] && !event['rawQueryString'].empty?
152
+ return event['rawQueryString']
153
+ end
154
+
155
+ params = event['multiValueQueryStringParameters'] || event['queryStringParameters']
156
+ return '' if params.nil? || params.empty?
157
+
158
+ pairs = []
159
+ params.each do |k, v|
160
+ if v.is_a?(Array)
161
+ v.each { |vv| pairs << [k, vv] }
162
+ else
163
+ pairs << [k, v]
164
+ end
165
+ end
166
+ URI.encode_www_form(pairs)
167
+ end
168
+
169
+ def extract_headers(event)
170
+ raw = event['headers'] || {}
171
+ multi = event['multiValueHeaders'] || {}
172
+
173
+ merged = {}
174
+ raw.each { |k, v| merged[k.downcase] = v }
175
+ multi.each do |k, values|
176
+ key = k.downcase
177
+ merged[key] = Array(values).join(',') unless merged.key?(key)
178
+ end
179
+ merged
180
+ end
181
+
182
+ def extract_body(event)
183
+ body = event['body']
184
+ return [StringIO.new(''.b), nil] if body.nil? || body.empty?
185
+
186
+ decoded = event['isBase64Encoded'] ? Base64.decode64(body) : body.dup
187
+ decoded = decoded.b
188
+ [StringIO.new(decoded), decoded.bytesize]
189
+ end
190
+
191
+ # ------------------------------------------------------------------
192
+ # Response: Rack triple -> Lambda response hash
193
+ # ------------------------------------------------------------------
194
+
195
+ def build_response(event, status, headers, body)
196
+ payload = collect_body(body)
197
+ encoded, is_base64 = maybe_base64(payload, headers)
198
+
199
+ flat_headers = {}
200
+ multi_headers = {}
201
+ (headers || {}).each do |name, value|
202
+ key = name.to_s
203
+ if value.is_a?(Array)
204
+ flat_headers[key] = value.join(',')
205
+ multi_headers[key] = value
206
+ else
207
+ flat_headers[key] = value.to_s
208
+ end
209
+ end
210
+
211
+ if detect_version(event) == 2
212
+ response = {
213
+ 'statusCode' => status.to_i,
214
+ 'headers' => flat_headers,
215
+ 'body' => encoded,
216
+ 'isBase64Encoded' => is_base64
217
+ }
218
+ response['cookies'] = multi_headers['set-cookie'] if multi_headers.key?('set-cookie')
219
+ response
220
+ else
221
+ {
222
+ 'statusCode' => status.to_i,
223
+ 'headers' => flat_headers,
224
+ 'multiValueHeaders' => multi_headers,
225
+ 'body' => encoded,
226
+ 'isBase64Encoded' => is_base64
227
+ }
228
+ end
229
+ end
230
+
231
+ def collect_body(body)
232
+ return ''.b if body.nil?
233
+
234
+ parts = []
235
+ body.each { |chunk| parts << chunk.to_s }
236
+ parts.join.b
237
+ end
238
+
239
+ # Lambda requires the 'body' field to be a UTF-8 string; binary
240
+ # responses have to be base64 encoded and flagged as such. Any byte
241
+ # that isn't valid UTF-8 forces base64 encoding.
242
+ def maybe_base64(payload, _headers)
243
+ if payload.force_encoding(Encoding::UTF_8).valid_encoding?
244
+ [payload.to_s, false]
245
+ else
246
+ [Base64.strict_encode64(payload), true]
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end