igniter 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +2 -2
- data/docs/API_V2.md +58 -0
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/README.md +3 -0
- data/examples/distributed_workflow.rb +52 -0
- data/examples/ringcentral_routing.rb +26 -35
- data/lib/igniter/compiler/compiled_graph.rb +20 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +43 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +75 -8
- data/lib/igniter/diagnostics/report.rb +102 -3
- data/lib/igniter/dsl/contract_builder.rb +109 -8
- data/lib/igniter/errors.rb +6 -1
- data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
- data/lib/igniter/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/branch_node.rb +9 -3
- data/lib/igniter/model/collection_node.rb +9 -3
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +91 -8
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- data/lib/igniter/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +4 -0
- metadata +36 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Server
|
|
7
|
+
module Handlers
|
|
8
|
+
class Base
|
|
9
|
+
def initialize(registry, store)
|
|
10
|
+
@registry = registry
|
|
11
|
+
@store = store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(params:, body:)
|
|
15
|
+
handle(params: params, body: body)
|
|
16
|
+
rescue Igniter::Server::Registry::RegistryError => e
|
|
17
|
+
json_error(e.message, status: 404)
|
|
18
|
+
rescue Igniter::Error => e
|
|
19
|
+
json_error(e.message, status: 422)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
json_error("Internal server error: #{e.message}", status: 500)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def handle(params:, body:)
|
|
27
|
+
raise NotImplementedError, "#{self.class}#handle must be implemented"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def json_ok(data)
|
|
31
|
+
{ status: 200, body: JSON.generate(data), headers: json_ct }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def json_error(message, status: 422)
|
|
35
|
+
{ status: status, body: JSON.generate({ error: message }), headers: json_ct }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def json_ct
|
|
39
|
+
{ "Content-Type" => "application/json" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Serialize a contract execution result into the standard API response hash.
|
|
43
|
+
# Reads the cache directly to avoid calling contract.pending?/failed? which
|
|
44
|
+
# internally re-invoke resolve_all and would re-raise on failed nodes.
|
|
45
|
+
def serialize_execution(contract, contract_class) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
46
|
+
execution_id = contract.execution.events.execution_id
|
|
47
|
+
cache_values = contract.execution.cache.values
|
|
48
|
+
|
|
49
|
+
if cache_values.any?(&:pending?)
|
|
50
|
+
pending_nodes = cache_values.select(&:pending?)
|
|
51
|
+
waiting_for = pending_nodes.filter_map { |s| s.value.payload[:event]&.to_s }
|
|
52
|
+
{ execution_id: execution_id, status: "pending", waiting_for: waiting_for }
|
|
53
|
+
elsif cache_values.any?(&:failed?)
|
|
54
|
+
error_state = cache_values.find(&:failed?)
|
|
55
|
+
{ execution_id: execution_id, status: "failed",
|
|
56
|
+
error: serialize_error(error_state&.error) }
|
|
57
|
+
else
|
|
58
|
+
outputs = serialize_outputs(contract, contract_class)
|
|
59
|
+
{ execution_id: execution_id, status: "succeeded", outputs: outputs }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def serialize_outputs(contract, contract_class)
|
|
64
|
+
contract_class.compiled_graph.outputs.each_with_object({}) do |output, memo|
|
|
65
|
+
memo[output.name] = to_json_value(contract.result.public_send(output.name))
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def serialize_error(error)
|
|
72
|
+
return { message: "Unknown error" } unless error
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
type: error.class.name,
|
|
76
|
+
message: error.message,
|
|
77
|
+
node: error.context[:node_name]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Recursively convert a value into JSON-safe primitives.
|
|
82
|
+
def to_json_value(value) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
|
|
83
|
+
case value
|
|
84
|
+
when Hash
|
|
85
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_s] = to_json_value(v) }
|
|
86
|
+
when Array
|
|
87
|
+
value.map { |v| to_json_value(v) }
|
|
88
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
89
|
+
value
|
|
90
|
+
when Symbol
|
|
91
|
+
value.to_s
|
|
92
|
+
else
|
|
93
|
+
value.respond_to?(:to_h) ? to_json_value(value.to_h) : value.to_s
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def symbolize_inputs(hash)
|
|
98
|
+
return {} unless hash.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
module Handlers
|
|
6
|
+
class ContractsHandler < Base
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle(params:, body:) # rubocop:disable Lint/UnusedMethodArgument
|
|
10
|
+
json_ok(@registry.introspect)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
module Handlers
|
|
6
|
+
class EventHandler < Base
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle(params:, body:) # rubocop:disable Metrics/MethodLength
|
|
10
|
+
contract_class = @registry.fetch(params[:name])
|
|
11
|
+
event = body["event"]&.to_sym
|
|
12
|
+
correlation = symbolize_inputs(body["correlation"] || {})
|
|
13
|
+
payload = body["payload"] || {}
|
|
14
|
+
|
|
15
|
+
raise Igniter::Error, "event name is required" unless event
|
|
16
|
+
|
|
17
|
+
contract = contract_class.deliver_event(event,
|
|
18
|
+
correlation: correlation,
|
|
19
|
+
payload: payload,
|
|
20
|
+
store: @store)
|
|
21
|
+
contract.resolve_all unless contract.success? || contract.failed?
|
|
22
|
+
|
|
23
|
+
json_ok(serialize_execution(contract, contract_class))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
module Handlers
|
|
6
|
+
class ExecuteHandler < Base
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle(params:, body:)
|
|
10
|
+
contract_class = @registry.fetch(params[:name])
|
|
11
|
+
inputs = symbolize_inputs(body["inputs"] || {})
|
|
12
|
+
contract = run_contract(contract_class, inputs)
|
|
13
|
+
json_ok(serialize_execution(contract, contract_class))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_contract(contract_class, inputs) # rubocop:disable Metrics/MethodLength
|
|
17
|
+
if use_distributed_start?(contract_class)
|
|
18
|
+
contract_class.start(inputs, store: @store)
|
|
19
|
+
else
|
|
20
|
+
contract = contract_class.new(inputs)
|
|
21
|
+
begin
|
|
22
|
+
contract.resolve_all
|
|
23
|
+
rescue Igniter::Error
|
|
24
|
+
nil # failed state is already written to cache; serialize_execution handles it
|
|
25
|
+
end
|
|
26
|
+
contract
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Use .start for contracts that declare correlate_by (distributed workflows).
|
|
31
|
+
def use_distributed_start?(contract_class)
|
|
32
|
+
contract_class.respond_to?(:correlation_keys) && !contract_class.correlation_keys.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
module Handlers
|
|
6
|
+
class HealthHandler < Base
|
|
7
|
+
def initialize(registry, store, node_url: nil)
|
|
8
|
+
super(registry, store)
|
|
9
|
+
@node_url = node_url
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def handle(params:, body:) # rubocop:disable Lint/UnusedMethodArgument,Metrics/MethodLength
|
|
15
|
+
pending_count = begin
|
|
16
|
+
@store.list_pending.size
|
|
17
|
+
rescue StandardError
|
|
18
|
+
0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
json_ok({
|
|
22
|
+
status: "ok",
|
|
23
|
+
node: @node_url,
|
|
24
|
+
contracts: @registry.names,
|
|
25
|
+
store: @store.class.name.split("::").last,
|
|
26
|
+
pending: pending_count
|
|
27
|
+
})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
module Handlers
|
|
6
|
+
class StatusHandler < Base
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle(params:, body:) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
10
|
+
execution_id = params[:id]
|
|
11
|
+
snapshot = @store.fetch(execution_id)
|
|
12
|
+
states = snapshot[:states] || snapshot["states"] || {}
|
|
13
|
+
|
|
14
|
+
pending = states.values.any? { |s| (s[:status] || s["status"]).to_s == "pending" }
|
|
15
|
+
failed = states.values.any? { |s| (s[:status] || s["status"]).to_s == "failed" }
|
|
16
|
+
|
|
17
|
+
status = if pending then "pending"
|
|
18
|
+
elsif failed then "failed"
|
|
19
|
+
else "succeeded"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
json_ok({ execution_id: execution_id, status: status })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Server
|
|
7
|
+
# Pure-Ruby HTTP/1.1 server built on TCPServer (stdlib, zero external deps).
|
|
8
|
+
# Spawns one thread per connection. Intended for development and orchestration use.
|
|
9
|
+
# For production, use RackApp with Puma via `Igniter::Server.rack_app`.
|
|
10
|
+
class HttpServer
|
|
11
|
+
CRLF = "\r\n"
|
|
12
|
+
STATUS_MESSAGES = {
|
|
13
|
+
200 => "OK",
|
|
14
|
+
400 => "Bad Request",
|
|
15
|
+
404 => "Not Found",
|
|
16
|
+
422 => "Unprocessable Entity",
|
|
17
|
+
500 => "Internal Server Error"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(config)
|
|
21
|
+
@config = config
|
|
22
|
+
@router = Router.new(config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start # rubocop:disable Metrics/MethodLength
|
|
26
|
+
@tcp_server = TCPServer.new(@config.host, @config.port)
|
|
27
|
+
@running = true
|
|
28
|
+
|
|
29
|
+
trap("INT") { stop }
|
|
30
|
+
trap("TERM") { stop }
|
|
31
|
+
|
|
32
|
+
log("igniter-server listening on http://#{@config.host}:#{@config.port}")
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
break unless @running
|
|
36
|
+
|
|
37
|
+
client = accept_connection
|
|
38
|
+
Thread.new(client) { |conn| handle_connection(conn) } if client
|
|
39
|
+
end
|
|
40
|
+
rescue IOError
|
|
41
|
+
# Server socket closed via stop
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stop
|
|
45
|
+
@running = false
|
|
46
|
+
@tcp_server&.close
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def accept_connection
|
|
52
|
+
@tcp_server.accept_nonblock
|
|
53
|
+
rescue IO::WaitReadable
|
|
54
|
+
IO.select([@tcp_server], nil, nil, 0.5)
|
|
55
|
+
nil
|
|
56
|
+
rescue IOError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def handle_connection(socket) # rubocop:disable Metrics/MethodLength
|
|
61
|
+
request_line = socket.gets&.chomp
|
|
62
|
+
return unless request_line&.include?(" ")
|
|
63
|
+
|
|
64
|
+
http_method, path, = request_line.split(" ", 3)
|
|
65
|
+
headers = read_headers(socket)
|
|
66
|
+
body = read_body(socket, headers["content-length"].to_i)
|
|
67
|
+
|
|
68
|
+
result = @router.call(http_method, path, body)
|
|
69
|
+
write_response(socket, result)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
log("Connection error: #{e.message}")
|
|
72
|
+
ensure
|
|
73
|
+
socket.close rescue nil # rubocop:disable Style/RescueModifier
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_headers(socket)
|
|
77
|
+
headers = {}
|
|
78
|
+
while (line = socket.gets&.chomp) && !line.empty?
|
|
79
|
+
name, value = line.split(": ", 2)
|
|
80
|
+
headers[name.downcase] = value if name
|
|
81
|
+
end
|
|
82
|
+
headers
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def read_body(socket, length)
|
|
86
|
+
length.positive? ? socket.read(length).to_s : ""
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write_response(socket, result)
|
|
90
|
+
body = result[:body].to_s
|
|
91
|
+
code = result[:status].to_i
|
|
92
|
+
phrase = STATUS_MESSAGES.fetch(code, "Unknown")
|
|
93
|
+
|
|
94
|
+
response = "HTTP/1.1 #{code} #{phrase}#{CRLF}"
|
|
95
|
+
response += "Content-Type: application/json#{CRLF}"
|
|
96
|
+
response += "Content-Length: #{body.bytesize}#{CRLF}"
|
|
97
|
+
response += "Connection: close#{CRLF}"
|
|
98
|
+
response += CRLF
|
|
99
|
+
response += body
|
|
100
|
+
|
|
101
|
+
socket.write(response)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def log(message)
|
|
105
|
+
@config.logger&.puts(message) || $stdout.puts(message)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
# Rack-compatible application adapter.
|
|
6
|
+
# Allows igniter-server to run under any Rack-compatible server (Puma, Unicorn, etc.).
|
|
7
|
+
#
|
|
8
|
+
# Usage in config.ru:
|
|
9
|
+
# require "igniter/server"
|
|
10
|
+
# Igniter::Server.configure { |c| c.register "MyContract", MyContract }
|
|
11
|
+
# run Igniter::Server.rack_app
|
|
12
|
+
class RackApp
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@router = Router.new(config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env) # rubocop:disable Metrics/MethodLength
|
|
18
|
+
method = env["REQUEST_METHOD"]
|
|
19
|
+
path = env["PATH_INFO"]
|
|
20
|
+
body_str = env["rack.input"].read
|
|
21
|
+
|
|
22
|
+
result = @router.call(method, path, body_str)
|
|
23
|
+
|
|
24
|
+
[
|
|
25
|
+
result[:status],
|
|
26
|
+
result[:headers].merge("Content-Length" => result[:body].bytesize.to_s),
|
|
27
|
+
[result[:body]]
|
|
28
|
+
]
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
error_body = JSON.generate({ error: "Internal server error: #{e.message}" })
|
|
31
|
+
[500, { "Content-Type" => "application/json" }, [error_body]]
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Server
|
|
5
|
+
class Registry
|
|
6
|
+
class RegistryError < Igniter::Server::Error; end
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@contracts = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(name, contract_class)
|
|
14
|
+
unless contract_class.is_a?(Class) && contract_class <= Igniter::Contract
|
|
15
|
+
raise RegistryError, "'#{name}' must be an Igniter::Contract subclass"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@mutex.synchronize { @contracts[name.to_s] = contract_class }
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch(name)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@contracts.fetch(name.to_s) do
|
|
25
|
+
raise RegistryError, "Contract '#{name}' is not registered. " \
|
|
26
|
+
"Available: #{@contracts.keys.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def all
|
|
32
|
+
@mutex.synchronize { @contracts.dup }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def names
|
|
36
|
+
@mutex.synchronize { @contracts.keys }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def registered?(name)
|
|
40
|
+
@mutex.synchronize { @contracts.key?(name.to_s) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns a description of each registered contract for introspection.
|
|
44
|
+
def introspect
|
|
45
|
+
all.map do |name, klass|
|
|
46
|
+
graph = klass.compiled_graph
|
|
47
|
+
{
|
|
48
|
+
name: name,
|
|
49
|
+
inputs: graph.nodes.select { |n| n.kind == :input }.map(&:name),
|
|
50
|
+
outputs: graph.outputs.map(&:name)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Server
|
|
7
|
+
# Transport-agnostic HTTP router.
|
|
8
|
+
# Receives (method, path, body_string) and returns { status:, body:, headers: }.
|
|
9
|
+
# Used by both HttpServer (TCPServer) and RackApp.
|
|
10
|
+
class Router
|
|
11
|
+
ROUTES = [
|
|
12
|
+
{ method: "GET", pattern: %r{\A/v1/health\z}, handler: :health },
|
|
13
|
+
{ method: "GET", pattern: %r{\A/v1/contracts\z}, handler: :contracts },
|
|
14
|
+
{ method: "POST", pattern: %r{\A/v1/contracts/(?<name>[^/]+)/execute\z}, handler: :execute },
|
|
15
|
+
{ method: "POST", pattern: %r{\A/v1/contracts/(?<name>[^/]+)/events\z}, handler: :event },
|
|
16
|
+
{ method: "GET", pattern: %r{\A/v1/executions/(?<id>[^/]+)\z}, handler: :status }
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Main dispatch entry point — called by both WEBrick and Rack adapters.
|
|
24
|
+
def call(http_method, path, body_str) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
25
|
+
method_uc = http_method.to_s.upcase
|
|
26
|
+
ROUTES.each do |route|
|
|
27
|
+
next unless route[:method] == method_uc
|
|
28
|
+
|
|
29
|
+
match = route[:pattern].match(path)
|
|
30
|
+
next unless match
|
|
31
|
+
|
|
32
|
+
params = match.named_captures.transform_keys(&:to_sym)
|
|
33
|
+
body = parse_body(body_str)
|
|
34
|
+
|
|
35
|
+
handler = build_handler(route[:handler])
|
|
36
|
+
return handler.call(params: params, body: body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
not_found_response(path)
|
|
40
|
+
rescue JSON::ParserError => e
|
|
41
|
+
{ status: 400, body: JSON.generate({ error: "Invalid JSON: #{e.message}" }), headers: json_ct }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def build_handler(key)
|
|
47
|
+
registry = @config.registry
|
|
48
|
+
store = @config.store
|
|
49
|
+
node_url = "http://#{@config.host}:#{@config.port}"
|
|
50
|
+
|
|
51
|
+
case key
|
|
52
|
+
when :health then Handlers::HealthHandler.new(registry, store, node_url: node_url)
|
|
53
|
+
when :contracts then Handlers::ContractsHandler.new(registry, store)
|
|
54
|
+
when :execute then Handlers::ExecuteHandler.new(registry, store)
|
|
55
|
+
when :event then Handlers::EventHandler.new(registry, store)
|
|
56
|
+
when :status then Handlers::StatusHandler.new(registry, store)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_body(str)
|
|
61
|
+
return {} if str.nil? || str.strip.empty?
|
|
62
|
+
|
|
63
|
+
JSON.parse(str)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def not_found_response(path)
|
|
67
|
+
{ status: 404, body: JSON.generate({ error: "Not found: #{path}" }), headers: json_ct }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def json_ct
|
|
71
|
+
{ "Content-Type" => "application/json" }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
# Define the top-level Server module and Error class first,
|
|
7
|
+
# so subfiles can inherit from Igniter::Server::Error.
|
|
8
|
+
module Igniter
|
|
9
|
+
module Server
|
|
10
|
+
class Error < Igniter::Error; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "server/registry"
|
|
15
|
+
require_relative "server/config"
|
|
16
|
+
require_relative "server/router"
|
|
17
|
+
require_relative "server/http_server"
|
|
18
|
+
require_relative "server/rack_app"
|
|
19
|
+
require_relative "server/client"
|
|
20
|
+
require_relative "server/handlers/base"
|
|
21
|
+
require_relative "server/handlers/health_handler"
|
|
22
|
+
require_relative "server/handlers/contracts_handler"
|
|
23
|
+
require_relative "server/handlers/execute_handler"
|
|
24
|
+
require_relative "server/handlers/event_handler"
|
|
25
|
+
require_relative "server/handlers/status_handler"
|
|
26
|
+
|
|
27
|
+
module Igniter
|
|
28
|
+
module Server
|
|
29
|
+
class << self
|
|
30
|
+
def config
|
|
31
|
+
@config ||= Config.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure
|
|
35
|
+
yield config
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Start the built-in HTTP server (blocking).
|
|
40
|
+
def start(**options)
|
|
41
|
+
apply_options!(options)
|
|
42
|
+
HttpServer.new(config).start
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Return a Rack-compatible application for use with Puma/Unicorn/etc.
|
|
46
|
+
def rack_app
|
|
47
|
+
RackApp.new(config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reset configuration (useful in tests).
|
|
51
|
+
def reset!
|
|
52
|
+
@config = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def apply_options!(options) # rubocop:disable Metrics/AbcSize
|
|
58
|
+
config.port = options[:port] if options[:port]
|
|
59
|
+
config.host = options[:host] if options[:host]
|
|
60
|
+
config.store = options[:store] if options[:store]
|
|
61
|
+
return unless options[:contracts].is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
options[:contracts].each { |name, klass| config.register(name.to_s, klass) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/igniter/version.rb
CHANGED