whoosh 1.0.1

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +413 -0
  4. data/exe/whoosh +6 -0
  5. data/lib/whoosh/app.rb +655 -0
  6. data/lib/whoosh/auth/access_control.rb +26 -0
  7. data/lib/whoosh/auth/api_key.rb +30 -0
  8. data/lib/whoosh/auth/jwt.rb +88 -0
  9. data/lib/whoosh/auth/oauth2.rb +33 -0
  10. data/lib/whoosh/auth/rate_limiter.rb +86 -0
  11. data/lib/whoosh/auth/token_tracker.rb +40 -0
  12. data/lib/whoosh/cache/memory_store.rb +57 -0
  13. data/lib/whoosh/cache/redis_store.rb +72 -0
  14. data/lib/whoosh/cache.rb +26 -0
  15. data/lib/whoosh/cli/generators.rb +133 -0
  16. data/lib/whoosh/cli/main.rb +277 -0
  17. data/lib/whoosh/cli/project_generator.rb +172 -0
  18. data/lib/whoosh/config.rb +160 -0
  19. data/lib/whoosh/database.rb +47 -0
  20. data/lib/whoosh/dependency_injection.rb +103 -0
  21. data/lib/whoosh/endpoint.rb +79 -0
  22. data/lib/whoosh/env_loader.rb +46 -0
  23. data/lib/whoosh/errors.rb +68 -0
  24. data/lib/whoosh/http/response.rb +26 -0
  25. data/lib/whoosh/http.rb +73 -0
  26. data/lib/whoosh/instrumentation.rb +22 -0
  27. data/lib/whoosh/job.rb +24 -0
  28. data/lib/whoosh/jobs/memory_backend.rb +45 -0
  29. data/lib/whoosh/jobs/worker.rb +73 -0
  30. data/lib/whoosh/jobs.rb +50 -0
  31. data/lib/whoosh/logger.rb +62 -0
  32. data/lib/whoosh/mcp/client.rb +71 -0
  33. data/lib/whoosh/mcp/client_manager.rb +73 -0
  34. data/lib/whoosh/mcp/protocol.rb +39 -0
  35. data/lib/whoosh/mcp/server.rb +66 -0
  36. data/lib/whoosh/mcp/transport/sse.rb +26 -0
  37. data/lib/whoosh/mcp/transport/stdio.rb +33 -0
  38. data/lib/whoosh/metrics.rb +84 -0
  39. data/lib/whoosh/middleware/cors.rb +61 -0
  40. data/lib/whoosh/middleware/plugin_hooks.rb +27 -0
  41. data/lib/whoosh/middleware/request_limit.rb +28 -0
  42. data/lib/whoosh/middleware/request_logger.rb +39 -0
  43. data/lib/whoosh/middleware/security_headers.rb +28 -0
  44. data/lib/whoosh/middleware/stack.rb +25 -0
  45. data/lib/whoosh/openapi/generator.rb +50 -0
  46. data/lib/whoosh/openapi/schema_converter.rb +48 -0
  47. data/lib/whoosh/openapi/ui.rb +62 -0
  48. data/lib/whoosh/paginate.rb +64 -0
  49. data/lib/whoosh/performance.rb +20 -0
  50. data/lib/whoosh/plugins/base.rb +42 -0
  51. data/lib/whoosh/plugins/registry.rb +139 -0
  52. data/lib/whoosh/request.rb +93 -0
  53. data/lib/whoosh/response.rb +39 -0
  54. data/lib/whoosh/router.rb +112 -0
  55. data/lib/whoosh/schema.rb +194 -0
  56. data/lib/whoosh/serialization/json.rb +73 -0
  57. data/lib/whoosh/serialization/msgpack.rb +51 -0
  58. data/lib/whoosh/serialization/negotiator.rb +37 -0
  59. data/lib/whoosh/serialization/protobuf.rb +43 -0
  60. data/lib/whoosh/shutdown.rb +30 -0
  61. data/lib/whoosh/storage/local.rb +24 -0
  62. data/lib/whoosh/storage/s3.rb +31 -0
  63. data/lib/whoosh/storage.rb +20 -0
  64. data/lib/whoosh/streaming/llm_stream.rb +51 -0
  65. data/lib/whoosh/streaming/sse.rb +61 -0
  66. data/lib/whoosh/streaming/stream_body.rb +59 -0
  67. data/lib/whoosh/streaming/websocket.rb +51 -0
  68. data/lib/whoosh/test.rb +70 -0
  69. data/lib/whoosh/types.rb +11 -0
  70. data/lib/whoosh/uploaded_file.rb +47 -0
  71. data/lib/whoosh/version.rb +5 -0
  72. data/lib/whoosh.rb +86 -0
  73. metadata +265 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ class DependencyInjection
5
+ def initialize
6
+ @providers = {}
7
+ @singletons = {}
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def provide(name, scope: :singleton, &block)
12
+ @providers[name] = { block: block, scope: scope }
13
+ @singletons.delete(name) # Clear cached value on re-register
14
+ end
15
+
16
+ def resolve(name, request: nil, resolving: [])
17
+ provider = @providers[name]
18
+ raise Errors::DependencyError, "Unknown dependency: #{name}" unless provider
19
+
20
+ if resolving.include?(name)
21
+ raise Errors::DependencyError, "Circular dependency detected: #{(resolving + [name]).join(' -> ')}"
22
+ end
23
+
24
+ case provider[:scope]
25
+ when :singleton
26
+ # Check cache without lock first (double-checked locking pattern)
27
+ return @singletons[name] if @singletons.key?(name)
28
+
29
+ # Compute outside the lock to allow recursive singleton resolution
30
+ value = call_provider(provider[:block], request: request, resolving: resolving + [name])
31
+ @mutex.synchronize { @singletons[name] ||= value }
32
+ @singletons[name]
33
+ when :request
34
+ call_provider(provider[:block], request: request, resolving: resolving + [name])
35
+ end
36
+ end
37
+
38
+ def inject_for(names, request: nil)
39
+ names.each_with_object({}) do |name, hash|
40
+ hash[name] = resolve(name, request: request)
41
+ end
42
+ end
43
+
44
+ def validate!
45
+ # Topological sort to detect circular deps and unknown refs at boot
46
+ visited = {}
47
+ sorted = []
48
+
49
+ visit = ->(name, path) do
50
+ return if visited[name] == :done
51
+ raise Errors::DependencyError, "Circular dependency detected: #{(path + [name]).join(' -> ')}" if visited[name] == :visiting
52
+
53
+ provider = @providers[name]
54
+ raise Errors::DependencyError, "Unknown dependency: #{name} (referenced by #{path.last})" unless provider
55
+
56
+ visited[name] = :visiting
57
+ deps = extract_deps(provider[:block])
58
+ deps.each { |dep| visit.call(dep, path + [name]) }
59
+ visited[name] = :done
60
+ sorted << name
61
+ end
62
+
63
+ @providers.each_key { |name| visit.call(name, []) unless visited[name] }
64
+ sorted
65
+ end
66
+
67
+ def registered?(name)
68
+ @providers.key?(name)
69
+ end
70
+
71
+ def close_all
72
+ @singletons.each_value do |instance|
73
+ instance.close if instance.respond_to?(:close)
74
+ end
75
+ @singletons.clear
76
+ end
77
+
78
+ private
79
+
80
+ def extract_deps(block)
81
+ block.parameters
82
+ .select { |type, _| type == :keyreq || type == :key }
83
+ .map(&:last)
84
+ end
85
+
86
+ def call_provider(block, request: nil, resolving: [])
87
+ # Inspect block parameters to determine what to inject
88
+ params = block.parameters
89
+ kwargs = params.select { |type, _| type == :keyreq || type == :key }.map(&:last)
90
+
91
+ if kwargs.any?
92
+ deps = kwargs.each_with_object({}) do |dep_name, hash|
93
+ hash[dep_name] = resolve(dep_name, request: request, resolving: resolving)
94
+ end
95
+ block.call(**deps)
96
+ elsif params.any? { |type, _| type == :req || type == :opt }
97
+ block.call(request)
98
+ else
99
+ block.call
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ class Endpoint
5
+ class Context
6
+ attr_reader :request
7
+
8
+ def initialize(app, request)
9
+ @app = app
10
+ @request = request
11
+ end
12
+
13
+ def respond_to_missing?(method_name, include_private = false)
14
+ @app.respond_to?(method_name, include_private) || super
15
+ end
16
+
17
+ private
18
+
19
+ def method_missing(method_name, ...)
20
+ if @app.respond_to?(method_name)
21
+ @app.send(method_name, ...)
22
+ else
23
+ super
24
+ end
25
+ end
26
+ end
27
+
28
+ class << self
29
+ def inherited(subclass)
30
+ super
31
+ subclass.instance_variable_set(:@declared_routes, [])
32
+ end
33
+
34
+ def declared_routes
35
+ @declared_routes
36
+ end
37
+
38
+ def get(path, **opts)
39
+ declare_route("GET", path, **opts)
40
+ end
41
+
42
+ def post(path, **opts)
43
+ declare_route("POST", path, **opts)
44
+ end
45
+
46
+ def put(path, **opts)
47
+ declare_route("PUT", path, **opts)
48
+ end
49
+
50
+ def patch(path, **opts)
51
+ declare_route("PATCH", path, **opts)
52
+ end
53
+
54
+ def delete(path, **opts)
55
+ declare_route("DELETE", path, **opts)
56
+ end
57
+
58
+ def options(path, **opts)
59
+ declare_route("OPTIONS", path, **opts)
60
+ end
61
+
62
+ private
63
+
64
+ def declare_route(method, path, request: nil, response: nil, **metadata)
65
+ @declared_routes << {
66
+ method: method,
67
+ path: path,
68
+ request_schema: request,
69
+ response_schema: response,
70
+ metadata: metadata
71
+ }
72
+ end
73
+ end
74
+
75
+ def call(req)
76
+ raise NotImplementedError, "#{self.class}#call must be implemented"
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,46 @@
1
+ # lib/whoosh/env_loader.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Whoosh
5
+ module EnvLoader
6
+ def self.load(root)
7
+ path = File.join(root, ".env")
8
+ return unless File.exist?(path)
9
+
10
+ if dotenv_available?
11
+ require "dotenv"
12
+ Dotenv.load(path)
13
+ return
14
+ end
15
+
16
+ parse(File.read(path)).each do |key, value|
17
+ ENV[key] ||= value
18
+ end
19
+ end
20
+
21
+ def self.parse(content)
22
+ pairs = {}
23
+ content.each_line do |line|
24
+ line = line.strip
25
+ next if line.empty? || line.start_with?("#")
26
+ key, value = line.split("=", 2)
27
+ next unless key && value
28
+ key = key.strip
29
+ value = value.strip
30
+ if (value.start_with?('"') && value.end_with?('"')) ||
31
+ (value.start_with?("'") && value.end_with?("'"))
32
+ value = value[1..-2]
33
+ end
34
+ pairs[key] = value
35
+ end
36
+ pairs
37
+ end
38
+
39
+ def self.dotenv_available?
40
+ require "dotenv"
41
+ true
42
+ rescue LoadError
43
+ false
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module Errors
5
+ class WhooshError < StandardError; end
6
+
7
+ class HttpError < WhooshError
8
+ attr_reader :status, :error_type
9
+
10
+ def initialize(message = nil, status: 500, error_type: "internal_error")
11
+ @status = status
12
+ @error_type = error_type
13
+ super(message || error_type)
14
+ end
15
+
16
+ def to_h
17
+ { error: error_type }
18
+ end
19
+ end
20
+
21
+ class ValidationError < HttpError
22
+ attr_reader :details
23
+
24
+ def initialize(details = [])
25
+ @details = details
26
+ super("Validation failed", status: 422, error_type: "validation_failed")
27
+ end
28
+
29
+ def to_h
30
+ { error: error_type, details: details }
31
+ end
32
+ end
33
+
34
+ class NotFoundError < HttpError
35
+ def initialize(message = "Not found")
36
+ super(message, status: 404, error_type: "not_found")
37
+ end
38
+ end
39
+
40
+ class UnauthorizedError < HttpError
41
+ def initialize(message = "Unauthorized")
42
+ super(message, status: 401, error_type: "unauthorized")
43
+ end
44
+ end
45
+
46
+ class ForbiddenError < HttpError
47
+ def initialize(message = "Forbidden")
48
+ super(message, status: 403, error_type: "forbidden")
49
+ end
50
+ end
51
+
52
+ class RateLimitExceeded < HttpError
53
+ attr_reader :retry_after
54
+
55
+ def initialize(message = "Rate limit exceeded", retry_after: 60)
56
+ @retry_after = retry_after
57
+ super(message, status: 429, error_type: "rate_limited")
58
+ end
59
+
60
+ def to_h
61
+ super.merge(retry_after: retry_after)
62
+ end
63
+ end
64
+
65
+ class DependencyError < WhooshError; end
66
+ class GuardrailsViolation < WhooshError; end
67
+ end
68
+ end
@@ -0,0 +1,26 @@
1
+ # lib/whoosh/http/response.rb
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ module Whoosh
7
+ module HTTP
8
+ class Response
9
+ attr_reader :status, :body, :headers
10
+
11
+ def initialize(status:, body:, headers: {})
12
+ @status = status
13
+ @body = body
14
+ @headers = headers
15
+ end
16
+
17
+ def json
18
+ @json ||= JSON.parse(@body)
19
+ end
20
+
21
+ def ok?
22
+ @status >= 200 && @status < 300
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,73 @@
1
+ # lib/whoosh/http.rb
2
+ # frozen_string_literal: true
3
+
4
+ require "net/http"
5
+ require "uri"
6
+ require "json"
7
+
8
+ module Whoosh
9
+ module HTTP
10
+ autoload :Response, "whoosh/http/response"
11
+
12
+ class TimeoutError < Errors::WhooshError; end
13
+ class ConnectionError < Errors::WhooshError; end
14
+
15
+ class << self
16
+ def get(url, headers: {}, timeout: 30)
17
+ request(:get, url, headers: headers, timeout: timeout)
18
+ end
19
+
20
+ def post(url, json: nil, body: nil, headers: {}, timeout: 30)
21
+ request(:post, url, json: json, body: body, headers: headers, timeout: timeout)
22
+ end
23
+
24
+ def put(url, json: nil, body: nil, headers: {}, timeout: 30)
25
+ request(:put, url, json: json, body: body, headers: headers, timeout: timeout)
26
+ end
27
+
28
+ def patch(url, json: nil, body: nil, headers: {}, timeout: 30)
29
+ request(:patch, url, json: json, body: body, headers: headers, timeout: timeout)
30
+ end
31
+
32
+ def delete(url, headers: {}, timeout: 30)
33
+ request(:delete, url, headers: headers, timeout: timeout)
34
+ end
35
+
36
+ private
37
+
38
+ def request(method, url, json: nil, body: nil, headers: {}, timeout: 30)
39
+ uri = URI.parse(url)
40
+ http = Net::HTTP.new(uri.host, uri.port)
41
+ http.use_ssl = (uri.scheme == "https")
42
+ http.open_timeout = timeout
43
+ http.read_timeout = timeout
44
+
45
+ request_body = json ? JSON.generate(json) : body
46
+ headers = { "Content-Type" => "application/json" }.merge(headers) if json
47
+
48
+ req = build_net_request(method, uri, headers)
49
+ req.body = request_body if request_body
50
+
51
+ response = http.request(req)
52
+ Response.new(status: response.code.to_i, body: response.body || "", headers: response.each_header.to_h)
53
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
54
+ raise TimeoutError, e.message
55
+ rescue Errno::ECONNREFUSED, SocketError, Errno::EHOSTUNREACH => e
56
+ raise ConnectionError, e.message
57
+ end
58
+
59
+ def build_net_request(method, uri, headers)
60
+ path = uri.request_uri
61
+ req = case method
62
+ when :get then Net::HTTP::Get.new(path)
63
+ when :post then Net::HTTP::Post.new(path)
64
+ when :put then Net::HTTP::Put.new(path)
65
+ when :patch then Net::HTTP::Patch.new(path)
66
+ when :delete then Net::HTTP::Delete.new(path)
67
+ end
68
+ headers.each { |k, v| req[k] = v }
69
+ req
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,22 @@
1
+ # lib/whoosh/instrumentation.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Whoosh
5
+ class Instrumentation
6
+ def initialize
7
+ @subscribers = Hash.new { |h, k| h[k] = [] }
8
+ end
9
+
10
+ def on(event, &block)
11
+ @subscribers[event] << block
12
+ end
13
+
14
+ def emit(event, data = {})
15
+ @subscribers[event].each do |subscriber|
16
+ subscriber.call(data)
17
+ rescue => e
18
+ # Don't let subscriber errors crash the app
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/whoosh/job.rb ADDED
@@ -0,0 +1,24 @@
1
+ # lib/whoosh/job.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Whoosh
5
+ class Job
6
+ class << self
7
+ def inject(*names)
8
+ @dependencies = names
9
+ end
10
+
11
+ def dependencies
12
+ @dependencies || []
13
+ end
14
+
15
+ def perform_async(**args)
16
+ Jobs.enqueue(self, **args)
17
+ end
18
+ end
19
+
20
+ def perform(**args)
21
+ raise NotImplementedError, "#{self.class}#perform must be implemented"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,45 @@
1
+ # lib/whoosh/jobs/memory_backend.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Whoosh
5
+ module Jobs
6
+ class MemoryBackend
7
+ def initialize
8
+ @queue = []
9
+ @records = {}
10
+ @mutex = Mutex.new
11
+ @cv = ConditionVariable.new
12
+ end
13
+
14
+ def push(job_data)
15
+ @mutex.synchronize do
16
+ @queue << job_data
17
+ @cv.signal
18
+ end
19
+ end
20
+
21
+ def pop(timeout: 5)
22
+ @mutex.synchronize do
23
+ @cv.wait(@mutex, timeout) if @queue.empty?
24
+ @queue.shift
25
+ end
26
+ end
27
+
28
+ def save(record)
29
+ @mutex.synchronize { @records[record[:id]] = record }
30
+ end
31
+
32
+ def find(id)
33
+ @mutex.synchronize { @records[id]&.dup }
34
+ end
35
+
36
+ def size
37
+ @mutex.synchronize { @queue.size }
38
+ end
39
+
40
+ def shutdown
41
+ @mutex.synchronize { @cv.broadcast }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,73 @@
1
+ # lib/whoosh/jobs/worker.rb
2
+ # frozen_string_literal: true
3
+
4
+ module Whoosh
5
+ module Jobs
6
+ class Worker
7
+ def initialize(backend:, di: nil, max_retries: 3, retry_delay: 5, instrumentation: nil)
8
+ @backend = backend
9
+ @di = di
10
+ @max_retries = max_retries
11
+ @retry_delay = retry_delay
12
+ @instrumentation = instrumentation
13
+ @running = true
14
+ end
15
+
16
+ def run_once(timeout: 5)
17
+ job_data = @backend.pop(timeout: timeout)
18
+ return unless job_data
19
+ execute(job_data)
20
+ end
21
+
22
+ def run_loop
23
+ while @running
24
+ run_once
25
+ end
26
+ end
27
+
28
+ def stop
29
+ @running = false
30
+ end
31
+
32
+ private
33
+
34
+ def execute(job_data)
35
+ id = job_data[:id]
36
+ record = @backend.find(id) || {}
37
+ record = record.merge(status: :running, started_at: Time.now.to_f)
38
+ @backend.save(record)
39
+
40
+ job_class = Object.const_get(job_data[:class_name])
41
+ job = job_class.new
42
+
43
+ # Inject DI deps
44
+ if @di && job_class.respond_to?(:dependencies)
45
+ job_class.dependencies.each do |dep|
46
+ value = @di.resolve(dep)
47
+ job.instance_variable_set(:"@#{dep}", value)
48
+ job.define_singleton_method(dep) { instance_variable_get(:"@#{dep}") }
49
+ end
50
+ end
51
+
52
+ args = job_data[:args].transform_keys(&:to_sym)
53
+ result = job.perform(**args)
54
+ serialized = Serialization::Json.decode(Serialization::Json.encode(result))
55
+
56
+ @backend.save(record.merge(status: :completed, result: serialized, completed_at: Time.now.to_f))
57
+ rescue => e
58
+ record = @backend.find(id) || {}
59
+ retry_count = (record[:retry_count] || 0) + 1
60
+
61
+ if retry_count <= @max_retries
62
+ sleep(@retry_delay) if @retry_delay > 0
63
+ @backend.save(record.merge(retry_count: retry_count, status: :pending))
64
+ @backend.push(job_data)
65
+ else
66
+ error = { message: e.message, backtrace: e.backtrace&.first(10)&.join("\n") }
67
+ @backend.save(record.merge(status: :failed, error: error, retry_count: retry_count, completed_at: Time.now.to_f))
68
+ @instrumentation&.emit(:job_failed, { job_id: id, error: error })
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,50 @@
1
+ # lib/whoosh/jobs.rb
2
+ # frozen_string_literal: true
3
+
4
+ require "securerandom"
5
+
6
+ module Whoosh
7
+ module Jobs
8
+ autoload :MemoryBackend, "whoosh/jobs/memory_backend"
9
+ autoload :Worker, "whoosh/jobs/worker"
10
+
11
+ @backend = nil
12
+ @di = nil
13
+
14
+ class << self
15
+ attr_reader :backend, :di
16
+
17
+ def configure(backend:, di: nil)
18
+ @backend = backend
19
+ @di = di
20
+ end
21
+
22
+ def configured?
23
+ !!@backend
24
+ end
25
+
26
+ def enqueue(job_class, **args)
27
+ raise Errors::DependencyError, "Jobs not configured — boot a Whoosh::App first" unless configured?
28
+ id = SecureRandom.uuid
29
+ record = {
30
+ id: id, class_name: job_class.name, args: args, status: :pending,
31
+ result: nil, error: nil, retry_count: 0,
32
+ created_at: Time.now.to_f, started_at: nil, completed_at: nil
33
+ }
34
+ @backend.save(record)
35
+ @backend.push({ id: id, class_name: job_class.name, args: args })
36
+ id
37
+ end
38
+
39
+ def find(id)
40
+ raise Errors::DependencyError, "Jobs not configured" unless configured?
41
+ @backend.find(id)
42
+ end
43
+
44
+ def reset!
45
+ @backend = nil
46
+ @di = nil
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Whoosh
6
+ class Logger
7
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
8
+
9
+ def initialize(output: $stdout, format: :json, level: :info)
10
+ @output = output
11
+ @format = format
12
+ @level = LEVELS[level.to_sym] || 1
13
+ end
14
+
15
+ def debug(event, **data)
16
+ log(:debug, event, **data)
17
+ end
18
+
19
+ def info(event, **data)
20
+ log(:info, event, **data)
21
+ end
22
+
23
+ def warn(event, **data)
24
+ log(:warn, event, **data)
25
+ end
26
+
27
+ def error(event, **data)
28
+ log(:error, event, **data)
29
+ end
30
+
31
+ def with_context(**context)
32
+ ScopedLogger.new(self, **context)
33
+ end
34
+
35
+ class ScopedLogger
36
+ def initialize(logger, **context)
37
+ @logger = logger
38
+ @context = context
39
+ end
40
+
41
+ def debug(event, **data) = @logger.debug(event, **@context, **data)
42
+ def info(event, **data) = @logger.info(event, **@context, **data)
43
+ def warn(event, **data) = @logger.warn(event, **@context, **data)
44
+ def error(event, **data) = @logger.error(event, **@context, **data)
45
+ end
46
+
47
+ private
48
+
49
+ def log(level, event, **data)
50
+ return if LEVELS[level] < @level
51
+
52
+ entry = { ts: Time.now.utc.iso8601, level: level.to_s, event: event }.merge(data)
53
+
54
+ case @format
55
+ when :json
56
+ @output.puts(JSON.generate(entry))
57
+ when :text
58
+ @output.puts("[#{entry[:ts]}] #{level.to_s.upcase} #{event} #{data.map { |k, v| "#{k}=#{v}" }.join(' ')}")
59
+ end
60
+ end
61
+ end
62
+ end