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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +2 -2
  4. data/docs/API_V2.md +58 -0
  5. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  6. data/examples/README.md +3 -0
  7. data/examples/distributed_workflow.rb +52 -0
  8. data/examples/ringcentral_routing.rb +26 -35
  9. data/lib/igniter/compiler/compiled_graph.rb +20 -0
  10. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  11. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  12. data/lib/igniter/compiler/validators/dependencies_validator.rb +43 -1
  13. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  14. data/lib/igniter/compiler.rb +2 -0
  15. data/lib/igniter/contract.rb +75 -8
  16. data/lib/igniter/diagnostics/report.rb +102 -3
  17. data/lib/igniter/dsl/contract_builder.rb +109 -8
  18. data/lib/igniter/errors.rb +6 -1
  19. data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
  20. data/lib/igniter/integrations/llm/config.rb +69 -0
  21. data/lib/igniter/integrations/llm/context.rb +74 -0
  22. data/lib/igniter/integrations/llm/executor.rb +159 -0
  23. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  24. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  25. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  26. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  27. data/lib/igniter/integrations/llm.rb +59 -0
  28. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  29. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  30. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  31. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  32. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  33. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  34. data/lib/igniter/integrations/rails.rb +12 -0
  35. data/lib/igniter/model/await_node.rb +21 -0
  36. data/lib/igniter/model/branch_node.rb +9 -3
  37. data/lib/igniter/model/collection_node.rb +9 -3
  38. data/lib/igniter/model/remote_node.rb +26 -0
  39. data/lib/igniter/model.rb +2 -0
  40. data/lib/igniter/runtime/execution.rb +2 -2
  41. data/lib/igniter/runtime/input_validator.rb +5 -3
  42. data/lib/igniter/runtime/resolver.rb +91 -8
  43. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  44. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  45. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  46. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  47. data/lib/igniter/server/client.rb +123 -0
  48. data/lib/igniter/server/config.rb +27 -0
  49. data/lib/igniter/server/handlers/base.rb +105 -0
  50. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  51. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  52. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  53. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  54. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  55. data/lib/igniter/server/http_server.rb +109 -0
  56. data/lib/igniter/server/rack_app.rb +35 -0
  57. data/lib/igniter/server/registry.rb +56 -0
  58. data/lib/igniter/server/router.rb +75 -0
  59. data/lib/igniter/server.rb +67 -0
  60. data/lib/igniter/version.rb +1 -1
  61. data/lib/igniter.rb +4 -0
  62. metadata +36 -2
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ module Providers
6
+ class Base
7
+ attr_reader :last_usage
8
+
9
+ def chat(messages:, model:, tools: [], **options)
10
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
11
+ end
12
+
13
+ def complete(prompt:, model:, system: nil, **options)
14
+ messages = []
15
+ messages << { role: "system", content: system } if system
16
+ messages << { role: "user", content: prompt }
17
+ response = chat(messages: messages, model: model, **options)
18
+ response[:content]
19
+ end
20
+
21
+ private
22
+
23
+ def record_usage(prompt_tokens: 0, completion_tokens: 0)
24
+ @last_usage = {
25
+ prompt_tokens: prompt_tokens,
26
+ completion_tokens: completion_tokens,
27
+ total_tokens: prompt_tokens + completion_tokens
28
+ }.freeze
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Igniter
8
+ module LLM
9
+ module Providers
10
+ # Ollama provider — calls the local Ollama REST API.
11
+ # Requires Ollama to be running: https://ollama.com
12
+ #
13
+ # Ollama API docs: https://github.com/ollama/ollama/blob/main/docs/api.md
14
+ class Ollama < Base
15
+ def initialize(base_url: "http://localhost:11434", timeout: 120)
16
+ super()
17
+ @base_url = base_url.chomp("/")
18
+ @timeout = timeout
19
+ end
20
+
21
+ # Send a chat completion request.
22
+ # Returns: { role: "assistant", content: "...", tool_calls: [...] }
23
+ def chat(messages:, model:, tools: [], **options) # rubocop:disable Metrics/MethodLength
24
+ body = {
25
+ model: model,
26
+ messages: normalize_messages(messages),
27
+ stream: false,
28
+ options: build_options(options)
29
+ }.compact
30
+
31
+ body[:tools] = normalize_tools(tools) if tools.any?
32
+
33
+ response = post("/api/chat", body)
34
+
35
+ message = response.fetch("message", {})
36
+ record_usage(
37
+ prompt_tokens: response["prompt_eval_count"] || 0,
38
+ completion_tokens: response["eval_count"] || 0
39
+ )
40
+
41
+ {
42
+ role: message.fetch("role", "assistant").to_sym,
43
+ content: message.fetch("content", ""),
44
+ tool_calls: parse_tool_calls(message["tool_calls"])
45
+ }
46
+ end
47
+
48
+ def models
49
+ get("/api/tags").fetch("models", []).map { |m| m["name"] }
50
+ end
51
+
52
+ private
53
+
54
+ def post(path, body)
55
+ uri = URI.parse("#{@base_url}#{path}")
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.read_timeout = @timeout
58
+ http.open_timeout = 10
59
+
60
+ request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
61
+ request.body = JSON.generate(body)
62
+
63
+ response = http.request(request)
64
+ handle_response(response)
65
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
66
+ raise Igniter::LLM::ProviderError, "Cannot connect to Ollama at #{@base_url}: #{e.message}"
67
+ end
68
+
69
+ def get(path)
70
+ uri = URI.parse("#{@base_url}#{path}")
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.open_timeout = 10
73
+ response = http.get(uri.path)
74
+ handle_response(response)
75
+ end
76
+
77
+ def handle_response(response)
78
+ unless response.is_a?(Net::HTTPSuccess)
79
+ raise Igniter::LLM::ProviderError,
80
+ "Ollama API error #{response.code}: #{response.body.to_s.slice(0, 200)}"
81
+ end
82
+
83
+ JSON.parse(response.body)
84
+ rescue JSON::ParserError => e
85
+ raise Igniter::LLM::ProviderError, "Ollama returned invalid JSON: #{e.message}"
86
+ end
87
+
88
+ def normalize_messages(messages)
89
+ messages.map do |msg|
90
+ { "role" => msg[:role].to_s, "content" => msg[:content].to_s }
91
+ end
92
+ end
93
+
94
+ def normalize_tools(tools)
95
+ tools.map do |tool|
96
+ {
97
+ "type" => "function",
98
+ "function" => {
99
+ "name" => tool[:name].to_s,
100
+ "description" => tool[:description].to_s,
101
+ "parameters" => tool.fetch(:parameters, { type: "object", properties: {} })
102
+ }
103
+ }
104
+ end
105
+ end
106
+
107
+ def parse_tool_calls(raw)
108
+ return [] unless raw.is_a?(Array)
109
+
110
+ raw.map do |tc|
111
+ fn = tc["function"] || tc
112
+ {
113
+ name: fn["name"].to_s,
114
+ arguments: parse_arguments(fn["arguments"])
115
+ }
116
+ end
117
+ end
118
+
119
+ def parse_arguments(args)
120
+ case args
121
+ when Hash then args.transform_keys(&:to_sym)
122
+ when String then JSON.parse(args).transform_keys(&:to_sym)
123
+ else {}
124
+ end
125
+ rescue JSON::ParserError
126
+ {}
127
+ end
128
+
129
+ def build_options(opts)
130
+ known = %i[temperature top_p top_k seed num_predict stop]
131
+ filtered = opts.slice(*known)
132
+ filtered.empty? ? nil : filtered.transform_keys(&:to_s)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Igniter
8
+ module LLM
9
+ module Providers
10
+ # OpenAI provider (also compatible with Azure OpenAI and any OpenAI-compatible API).
11
+ # Requires OPENAI_API_KEY environment variable or explicit api_key:.
12
+ #
13
+ # API docs: https://platform.openai.com/docs/api-reference/chat
14
+ #
15
+ # Compatible with: OpenAI, Azure OpenAI, Groq, Together AI,
16
+ # Mistral, DeepSeek, and any OpenAI-compatible endpoint.
17
+ class OpenAI < Base # rubocop:disable Metrics/ClassLength
18
+ API_BASE = "https://api.openai.com"
19
+
20
+ def initialize(api_key: ENV["OPENAI_API_KEY"], base_url: API_BASE, timeout: 120)
21
+ super()
22
+ @api_key = api_key
23
+ @base_url = base_url.chomp("/")
24
+ @timeout = timeout
25
+ end
26
+
27
+ # Send a chat completion request.
28
+ def chat(messages:, model:, tools: [], **options) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
29
+ validate_api_key!
30
+
31
+ body = {
32
+ model: model,
33
+ messages: normalize_messages(messages)
34
+ }
35
+ body[:tools] = normalize_tools(tools) if tools.any?
36
+ body[:temperature] = options[:temperature] if options.key?(:temperature)
37
+ body[:top_p] = options[:top_p] if options.key?(:top_p)
38
+ body[:max_tokens] = options[:max_tokens] if options.key?(:max_tokens)
39
+ body[:seed] = options[:seed] if options.key?(:seed)
40
+ body[:stop] = options[:stop] if options.key?(:stop)
41
+
42
+ response = post("/v1/chat/completions", body)
43
+ parse_response(response)
44
+ end
45
+
46
+ private
47
+
48
+ def parse_response(response) # rubocop:disable Metrics/MethodLength
49
+ message = response.dig("choices", 0, "message") || {}
50
+ usage = response.fetch("usage", {})
51
+
52
+ record_usage(
53
+ prompt_tokens: usage["prompt_tokens"] || 0,
54
+ completion_tokens: usage["completion_tokens"] || 0
55
+ )
56
+
57
+ {
58
+ role: (message["role"] || "assistant").to_sym,
59
+ content: message["content"].to_s,
60
+ tool_calls: parse_tool_calls(message["tool_calls"])
61
+ }
62
+ end
63
+
64
+ def parse_tool_calls(raw)
65
+ return [] unless raw.is_a?(Array)
66
+
67
+ raw.map do |tc|
68
+ fn = tc["function"] || {}
69
+ {
70
+ name: fn["name"].to_s,
71
+ arguments: parse_arguments(fn["arguments"])
72
+ }
73
+ end
74
+ end
75
+
76
+ def parse_arguments(args)
77
+ case args
78
+ when Hash then args.transform_keys(&:to_sym)
79
+ when String then JSON.parse(args).transform_keys(&:to_sym)
80
+ else {}
81
+ end
82
+ rescue JSON::ParserError
83
+ {}
84
+ end
85
+
86
+ def normalize_messages(messages)
87
+ messages.map do |m|
88
+ { "role" => (m[:role] || m["role"]).to_s, "content" => (m[:content] || m["content"]).to_s }
89
+ end
90
+ end
91
+
92
+ def normalize_tools(tools)
93
+ tools.map do |tool|
94
+ {
95
+ "type" => "function",
96
+ "function" => {
97
+ "name" => tool[:name].to_s,
98
+ "description" => tool[:description].to_s,
99
+ "parameters" => tool.fetch(:parameters) { { "type" => "object", "properties" => {} } }
100
+ }
101
+ }
102
+ end
103
+ end
104
+
105
+ def post(path, body) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
106
+ uri = URI.parse("#{@base_url}#{path}")
107
+ http = Net::HTTP.new(uri.host, uri.port)
108
+ http.use_ssl = uri.scheme == "https"
109
+ http.read_timeout = @timeout
110
+ http.open_timeout = 10
111
+
112
+ request = Net::HTTP::Post.new(uri.path, headers)
113
+ request.body = JSON.generate(body)
114
+
115
+ response = http.request(request)
116
+ handle_response(response)
117
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
118
+ raise Igniter::LLM::ProviderError, "Cannot connect to OpenAI API at #{@base_url}: #{e.message}"
119
+ end
120
+
121
+ def headers
122
+ {
123
+ "Content-Type" => "application/json",
124
+ "Authorization" => "Bearer #{@api_key}"
125
+ }
126
+ end
127
+
128
+ def handle_response(response) # rubocop:disable Metrics/MethodLength
129
+ unless response.is_a?(Net::HTTPSuccess)
130
+ body = begin
131
+ JSON.parse(response.body)
132
+ rescue StandardError
133
+ {}
134
+ end
135
+ error_msg = body.dig("error", "message") || response.body.to_s.slice(0, 200)
136
+ raise Igniter::LLM::ProviderError, "OpenAI API error #{response.code}: #{error_msg}"
137
+ end
138
+
139
+ JSON.parse(response.body)
140
+ rescue JSON::ParserError => e
141
+ raise Igniter::LLM::ProviderError, "OpenAI returned invalid JSON: #{e.message}"
142
+ end
143
+
144
+ def validate_api_key!
145
+ return if @api_key && !@api_key.empty?
146
+
147
+ raise Igniter::LLM::ConfigurationError,
148
+ "OpenAI API key not configured. Set OPENAI_API_KEY or pass api_key: to the provider."
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require_relative "llm/config"
5
+ require_relative "llm/context"
6
+ require_relative "llm/providers/base"
7
+ require_relative "llm/providers/ollama"
8
+ require_relative "llm/providers/anthropic"
9
+ require_relative "llm/providers/openai"
10
+ require_relative "llm/executor"
11
+
12
+ module Igniter
13
+ module LLM
14
+ class Error < Igniter::Error; end
15
+ class ProviderError < Error; end
16
+ class ConfigurationError < Error; end
17
+
18
+ AVAILABLE_PROVIDERS = Config::PROVIDERS
19
+
20
+ class << self
21
+ def config
22
+ @config ||= Config.new
23
+ end
24
+
25
+ def configure
26
+ yield config
27
+ end
28
+
29
+ # Returns a memoized provider instance for the given provider name.
30
+ def provider_instance(name)
31
+ @provider_instances ||= {}
32
+ @provider_instances[name.to_sym] ||= build_provider(name.to_sym)
33
+ end
34
+
35
+ # Reset cached provider instances (useful after reconfiguration).
36
+ def reset_providers!
37
+ @provider_instances = nil
38
+ end
39
+
40
+ private
41
+
42
+ def build_provider(name)
43
+ case name
44
+ when :ollama
45
+ cfg = config.ollama
46
+ Providers::Ollama.new(base_url: cfg.base_url, timeout: cfg.timeout)
47
+ when :anthropic
48
+ cfg = config.anthropic
49
+ Providers::Anthropic.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
50
+ when :openai
51
+ cfg = config.openai
52
+ Providers::OpenAI.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
53
+ else
54
+ raise ConfigurationError, "Unknown LLM provider: #{name}. Available: #{AVAILABLE_PROVIDERS.inspect}"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ # ActionCable channel mixin for streaming contract execution events.
6
+ #
7
+ # Usage:
8
+ # class OrderChannel < ApplicationCable::Channel
9
+ # include Igniter::Rails::CableAdapter
10
+ #
11
+ # subscribed do
12
+ # stream_contract(OrderContract, execution_id: params[:execution_id])
13
+ # end
14
+ # end
15
+ #
16
+ # Broadcasts events as:
17
+ # { type: "node_succeeded", node: "payment", status: "succeeded", payload: { ... } }
18
+ module CableAdapter
19
+ def stream_contract(contract_class, execution_id:, store: nil)
20
+ resolved_store = store || Igniter.execution_store
21
+ snapshot = resolved_store.fetch(execution_id)
22
+ instance = contract_class.restore(snapshot)
23
+
24
+ instance.subscribe do |event|
25
+ broadcast_igniter_event(event, execution_id)
26
+ end
27
+
28
+ @_igniter_executions ||= []
29
+ @_igniter_executions << instance
30
+ rescue Igniter::ResolutionError => e
31
+ transmit({ type: "error", message: e.message })
32
+ end
33
+
34
+ private
35
+
36
+ def broadcast_igniter_event(event, execution_id)
37
+ transmit({
38
+ type: event.type.to_s,
39
+ execution_id: execution_id,
40
+ node: event.node_name,
41
+ path: event.path,
42
+ status: event.status,
43
+ payload: event.payload,
44
+ timestamp: event.timestamp&.iso8601
45
+ }.compact)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ # Base ActiveJob class for async contract execution.
6
+ #
7
+ # Usage:
8
+ # class ProcessOrderJob < Igniter::Rails::ContractJob
9
+ # contract OrderContract
10
+ # end
11
+ #
12
+ # ProcessOrderJob.perform_later(order_id: "ord-123")
13
+ # ProcessOrderJob.perform_now(order_id: "ord-123")
14
+ #
15
+ # The job starts the contract and persists it to the configured store.
16
+ # If the contract has correlation keys, the execution can be resumed
17
+ # later via Contract.deliver_event.
18
+ class ContractJob
19
+ # No dependency on ActiveJob here — this class acts as a blueprint.
20
+ # When Rails is present, subclasses inherit from ApplicationJob automatically
21
+ # if the user adds `< ApplicationJob` (the recommended pattern).
22
+
23
+ class << self
24
+ def contract(klass = nil)
25
+ @contract_class = klass if klass
26
+ @contract_class
27
+ end
28
+
29
+ def store(store_instance = nil)
30
+ @store = store_instance if store_instance
31
+ @store || Igniter.execution_store
32
+ end
33
+
34
+ # Wraps perform in an ActiveJob-compatible interface.
35
+ # Call this when Rails is available to get ActiveJob queueing.
36
+ def perform_later(**inputs)
37
+ if defined?(::ActiveJob::Base)
38
+ ActiveJobAdapter.perform_later(contract_class: contract, inputs: inputs, store: store)
39
+ else
40
+ perform_now(**inputs)
41
+ end
42
+ end
43
+
44
+ def perform_now(**inputs)
45
+ contract.start(inputs, store: store)
46
+ end
47
+ end
48
+
49
+ # Included by ActiveJobAdapter to bridge ActiveJob lifecycle.
50
+ module Perform
51
+ def perform(contract_class_name:, inputs:, store_class: nil, store_config: nil)
52
+ klass = Object.const_get(contract_class_name)
53
+ resolved_store = resolve_store(store_class, store_config)
54
+ klass.start(inputs.transform_keys(&:to_sym), store: resolved_store)
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_store(store_class, _store_config)
60
+ return Igniter.execution_store unless store_class
61
+
62
+ Object.const_get(store_class).new
63
+ end
64
+ end
65
+ end
66
+
67
+ # ActiveJob adapter — only defined when ActiveJob is available.
68
+ if defined?(::ActiveJob::Base)
69
+ class ActiveJobAdapter < ::ActiveJob::Base
70
+ include ContractJob::Perform
71
+
72
+ queue_as :igniter
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Igniter
6
+ module Rails
7
+ module Generators
8
+ class ContractGenerator < ::Rails::Generators::NamedBase
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Creates an Igniter contract."
11
+
12
+ class_option :correlate_by, type: :array, default: [], desc: "Correlation key names"
13
+ class_option :inputs, type: :array, default: [], desc: "Input names"
14
+ class_option :outputs, type: :array, default: ["result"], desc: "Output names"
15
+
16
+ def create_contract
17
+ template "contract.rb.tt", File.join("app/contracts", class_path, "#{file_name}_contract.rb")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Igniter
6
+ module Rails
7
+ module Generators
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Creates an Igniter initializer in your application."
11
+
12
+ def copy_initializer
13
+ template "igniter.rb.tt", "config/initializers/igniter.rb"
14
+ end
15
+
16
+ def create_contracts_directory
17
+ empty_directory "app/contracts"
18
+ create_file "app/contracts/.keep"
19
+ end
20
+
21
+ def show_readme
22
+ say "", :green
23
+ say "✓ Igniter installed!", :green
24
+ say ""
25
+ say "Next steps:"
26
+ say " 1. Configure your store in config/initializers/igniter.rb"
27
+ say " 2. Generate a contract: rails g igniter:contract YourContractName"
28
+ say ""
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "igniter.configure_store" do
7
+ # Auto-configure store based on available adapters unless already set
8
+ next if Igniter.instance_variable_defined?(:@execution_store)
9
+
10
+ Igniter.execution_store =
11
+ if defined?(Redis) && ::Rails.application.config.respond_to?(:redis)
12
+ Igniter::Runtime::Stores::RedisStore.new(::Redis.current)
13
+ else
14
+ Igniter::Runtime::Stores::MemoryStore.new
15
+ end
16
+ end
17
+
18
+ initializer "igniter.load_contracts" do
19
+ ::Rails.autoloaders.main.on_load("ApplicationContract") do
20
+ # Hook point for future eager loading of compiled contracts
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ # Controller mixin for delivering external events to running contracts.
6
+ #
7
+ # Usage:
8
+ # class WebhooksController < ApplicationController
9
+ # include Igniter::Rails::WebhookHandler
10
+ #
11
+ # def stripe
12
+ # deliver_event_for(
13
+ # OrderContract,
14
+ # event: :stripe_payment_succeeded,
15
+ # correlation_from: { order_id: params[:metadata][:order_id] },
16
+ # payload: params.to_unsafe_h
17
+ # )
18
+ # end
19
+ # end
20
+ module WebhookHandler
21
+ def deliver_event_for(contract_class, event:, correlation_from:, payload: nil, store: nil) # rubocop:disable Metrics/MethodLength
22
+ payload_data = payload || (respond_to?(:params) ? params.to_unsafe_h : {})
23
+ correlation = extract_correlation(correlation_from)
24
+
25
+ contract_class.deliver_event(
26
+ event,
27
+ correlation: correlation,
28
+ payload: payload_data,
29
+ store: store || Igniter.execution_store
30
+ )
31
+
32
+ head :ok
33
+ rescue Igniter::ResolutionError => e
34
+ render json: { error: e.message }, status: :unprocessable_entity
35
+ end
36
+
37
+ private
38
+
39
+ def extract_correlation(source)
40
+ case source
41
+ when Hash then source.transform_keys(&:to_sym)
42
+ when Symbol then { source => params[source] }
43
+ when Array then source.each_with_object({}) { |k, h| h[k.to_sym] = params[k] }
44
+ else source.to_h.transform_keys(&:to_sym)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require_relative "rails/railtie" if defined?(::Rails::Railtie)
5
+ require_relative "rails/contract_job"
6
+ require_relative "rails/webhook_concern"
7
+ require_relative "rails/cable_adapter"
8
+
9
+ module Igniter
10
+ module Rails
11
+ end
12
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Model
5
+ class AwaitNode < Node
6
+ attr_reader :event_name
7
+
8
+ def initialize(id:, name:, event_name:, path: nil, metadata: {})
9
+ super(
10
+ id: id,
11
+ kind: :await,
12
+ name: name,
13
+ path: path || name.to_s,
14
+ dependencies: [],
15
+ metadata: metadata
16
+ )
17
+ @event_name = event_name.to_sym
18
+ end
19
+ end
20
+ end
21
+ end