autonoma-ai 0.2.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 +7 -0
- data/lib/autonoma/errors.rb +44 -0
- data/lib/autonoma/factory.rb +34 -0
- data/lib/autonoma/fingerprint.rb +28 -0
- data/lib/autonoma/graph.rb +145 -0
- data/lib/autonoma/handler.rb +312 -0
- data/lib/autonoma/hmac.rb +34 -0
- data/lib/autonoma/payload_topo.rb +242 -0
- data/lib/autonoma/refs.rb +73 -0
- data/lib/autonoma/schema.rb +148 -0
- data/lib/autonoma/types.rb +48 -0
- data/lib/autonoma.rb +16 -0
- data/lib/autonoma_rails/server.rb +94 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: eadf302b401291f69a0046c7fa35232a31266b25babdcf681fa526eb55a031c7
|
|
4
|
+
data.tar.gz: b67501339a7e2b550e11c68edbd12a244565e6dde13f93f5661dfd37325b0ab4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 824aa95f01aa67791daac6eb17ae4750527b58f80793244c0c16237946415f35cd34cd8a0c25a16b3be9225ae101463b45baaa7cec5383bd407e5914d7eb1581
|
|
7
|
+
data.tar.gz: 5b051dac04a96a1949d02fd1e6ea2a74883f3a6d4874df25f57cc0a381d9fc91ec711a07e0439facb288bf0c28deab376e593b7346e01fb800420900657633ad
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Autonoma
|
|
4
|
+
class AutonomaError < StandardError
|
|
5
|
+
attr_reader :message, :code, :status
|
|
6
|
+
|
|
7
|
+
def initialize(message, code, status)
|
|
8
|
+
@message = message
|
|
9
|
+
@code = code
|
|
10
|
+
@status = status
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Errors
|
|
16
|
+
def self.invalid_signature
|
|
17
|
+
AutonomaError.new("Invalid signature", "INVALID_SIGNATURE", 401)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.invalid_body(detail)
|
|
21
|
+
AutonomaError.new("Invalid body: #{detail}", "INVALID_BODY", 400)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.unknown_action(action)
|
|
25
|
+
AutonomaError.new("Unknown action: #{action}", "UNKNOWN_ACTION", 400)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.production_blocked
|
|
29
|
+
AutonomaError.new("Blocked in production", "PRODUCTION_BLOCKED", 404)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.invalid_refs_token(detail)
|
|
33
|
+
AutonomaError.new("Invalid refs token: #{detail}", "INVALID_REFS_TOKEN", 403)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.same_secrets
|
|
37
|
+
AutonomaError.new(
|
|
38
|
+
"sharedSecret and signingSecret must be different. The shared secret is known by Autonoma; the signing secret must be private.",
|
|
39
|
+
"SAME_SECRETS",
|
|
40
|
+
500
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "types"
|
|
4
|
+
|
|
5
|
+
module Autonoma
|
|
6
|
+
module Factory
|
|
7
|
+
# Define a factory for creating entities.
|
|
8
|
+
#
|
|
9
|
+
# input_fields is required: an array of hashes {name:, type:, required:}
|
|
10
|
+
# where type is one of 'string', 'integer', 'number', 'boolean',
|
|
11
|
+
# 'timestamp', 'date', 'uuid', 'json'.
|
|
12
|
+
#
|
|
13
|
+
# The factory's create callable receives a validated Hash and a
|
|
14
|
+
# FactoryContext, and must return a Hash with at least an "id" key.
|
|
15
|
+
#
|
|
16
|
+
# teardown is optional; without it the SDK has no way to remove rows
|
|
17
|
+
# the factory created.
|
|
18
|
+
def self.define_factory(create:, input_fields:, teardown: nil)
|
|
19
|
+
raise ArgumentError, 'Factory definition must include a callable "create"' unless create.respond_to?(:call)
|
|
20
|
+
|
|
21
|
+
if teardown && !teardown.respond_to?(:call)
|
|
22
|
+
raise ArgumentError, 'Factory "teardown" must be callable if provided'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if input_fields.nil? || !input_fields.is_a?(Array) || input_fields.empty?
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"Factory must declare `input_fields: [...]`. The SDK derives the discover " \
|
|
28
|
+
"schema from it; there is no automatic fallback."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
FactoryDefinition.new(create: create, teardown: teardown, input_fields: input_fields)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Autonoma
|
|
7
|
+
module Fingerprint
|
|
8
|
+
# Compute a 16-char hex fingerprint of any JSON-serializable value.
|
|
9
|
+
def self.fingerprint(value)
|
|
10
|
+
normalized = sort_keys(value)
|
|
11
|
+
json_str = JSON.generate(normalized)
|
|
12
|
+
OpenSSL::Digest::SHA256.hexdigest(json_str)[0, 16]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.sort_keys(obj)
|
|
16
|
+
case obj
|
|
17
|
+
when Hash
|
|
18
|
+
obj.sort.to_h.transform_values { |v| sort_keys(v) }
|
|
19
|
+
when Array
|
|
20
|
+
obj.map { |v| sort_keys(v) }
|
|
21
|
+
else
|
|
22
|
+
obj
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method :sort_keys
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Autonoma
|
|
6
|
+
module Graph
|
|
7
|
+
# Topological sort of nodes by FK dependency edges.
|
|
8
|
+
# Returns {"sorted" => [...], "cycles" => [[...]]}.
|
|
9
|
+
def self.topo_sort(nodes, edges)
|
|
10
|
+
node_set = nodes.to_set
|
|
11
|
+
|
|
12
|
+
# Filter to only edges between known nodes, skip self-referential
|
|
13
|
+
relevant_edges = edges.select do |e|
|
|
14
|
+
e["from"] != e["to"] && node_set.include?(e["from"]) && node_set.include?(e["to"])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Build in-degree map and adjacency list
|
|
18
|
+
in_degree = nodes.each_with_object({}) { |n, h| h[n] = 0 }
|
|
19
|
+
adj = Hash.new { |h, k| h[k] = [] }
|
|
20
|
+
|
|
21
|
+
relevant_edges.each do |e|
|
|
22
|
+
# e["from"] depends on e["to"] (from has the FK pointing to to)
|
|
23
|
+
adj[e["to"]] << e["from"]
|
|
24
|
+
in_degree[e["from"]] = (in_degree[e["from"]] || 0) + 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# First pass: standard Kahn's
|
|
28
|
+
queue = in_degree.select { |_, d| d == 0 }.keys.sort
|
|
29
|
+
sorted_nodes = []
|
|
30
|
+
|
|
31
|
+
until queue.empty?
|
|
32
|
+
node = queue.shift
|
|
33
|
+
sorted_nodes << node
|
|
34
|
+
(adj[node] || []).each do |neighbor|
|
|
35
|
+
in_degree[neighbor] -= 1
|
|
36
|
+
queue << neighbor if in_degree[neighbor] == 0
|
|
37
|
+
end
|
|
38
|
+
queue.sort!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Find unsorted nodes
|
|
42
|
+
unsorted = nodes.reject { |n| sorted_nodes.include?(n) }
|
|
43
|
+
|
|
44
|
+
return { "sorted" => sorted_nodes, "cycles" => [] } if unsorted.empty?
|
|
45
|
+
|
|
46
|
+
cycles = find_sccs(unsorted, relevant_edges)
|
|
47
|
+
|
|
48
|
+
return { "sorted" => sorted_nodes, "cycles" => [] } if cycles.empty?
|
|
49
|
+
|
|
50
|
+
# Second pass: treat cycle nodes as resolved and re-sort remaining
|
|
51
|
+
cycle_nodes = cycles.flatten.to_set
|
|
52
|
+
still_unsorted = unsorted.reject { |n| cycle_nodes.include?(n) }
|
|
53
|
+
|
|
54
|
+
if still_unsorted.any?
|
|
55
|
+
still_set = still_unsorted.to_set
|
|
56
|
+
in_deg2 = still_unsorted.each_with_object({}) { |n, h| h[n] = 0 }
|
|
57
|
+
adj2 = Hash.new { |h, k| h[k] = [] }
|
|
58
|
+
|
|
59
|
+
relevant_edges.each do |e|
|
|
60
|
+
if still_set.include?(e["from"]) && still_set.include?(e["to"])
|
|
61
|
+
adj2[e["to"]] << e["from"]
|
|
62
|
+
in_deg2[e["from"]] = (in_deg2[e["from"]] || 0) + 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
queue2 = in_deg2.select { |_, d| d == 0 }.keys.sort
|
|
67
|
+
until queue2.empty?
|
|
68
|
+
node = queue2.shift
|
|
69
|
+
sorted_nodes << node
|
|
70
|
+
(adj2[node] || []).each do |neighbor|
|
|
71
|
+
in_deg2[neighbor] -= 1
|
|
72
|
+
queue2 << neighbor if in_deg2[neighbor] == 0
|
|
73
|
+
end
|
|
74
|
+
queue2.sort!
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{ "sorted" => sorted_nodes, "cycles" => cycles }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Find a nullable FK edge in a cycle that can be deferred.
|
|
82
|
+
def self.find_deferrable_edge(cycle, edges)
|
|
83
|
+
cycle_set = cycle.to_set
|
|
84
|
+
edges.find do |e|
|
|
85
|
+
cycle_set.include?(e["from"]) &&
|
|
86
|
+
cycle_set.include?(e["to"]) &&
|
|
87
|
+
e["from"] != e["to"] &&
|
|
88
|
+
e["nullable"] == true
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Tarjan's SCC algorithm to identify exact cycles among remaining nodes.
|
|
93
|
+
def self.find_sccs(nodes, edges)
|
|
94
|
+
node_set = nodes.to_set
|
|
95
|
+
adj = Hash.new { |h, k| h[k] = [] }
|
|
96
|
+
|
|
97
|
+
edges.each do |e|
|
|
98
|
+
adj[e["to"]] << e["from"] if node_set.include?(e["from"]) && node_set.include?(e["to"])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
index_counter = [0]
|
|
102
|
+
stack = []
|
|
103
|
+
on_stack = Set.new
|
|
104
|
+
indices = {}
|
|
105
|
+
lowlinks = {}
|
|
106
|
+
sccs = []
|
|
107
|
+
|
|
108
|
+
strong_connect = lambda do |v|
|
|
109
|
+
indices[v] = index_counter[0]
|
|
110
|
+
lowlinks[v] = index_counter[0]
|
|
111
|
+
index_counter[0] += 1
|
|
112
|
+
stack.push(v)
|
|
113
|
+
on_stack.add(v)
|
|
114
|
+
|
|
115
|
+
(adj[v] || []).each do |w|
|
|
116
|
+
if !indices.key?(w)
|
|
117
|
+
strong_connect.call(w)
|
|
118
|
+
lowlinks[v] = [lowlinks[v], lowlinks[w]].min
|
|
119
|
+
elsif on_stack.include?(w)
|
|
120
|
+
lowlinks[v] = [lowlinks[v], indices[w]].min
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if lowlinks[v] == indices[v]
|
|
125
|
+
scc = []
|
|
126
|
+
loop do
|
|
127
|
+
w = stack.pop
|
|
128
|
+
on_stack.delete(w)
|
|
129
|
+
scc << w
|
|
130
|
+
break if w == v
|
|
131
|
+
end
|
|
132
|
+
sccs << scc if scc.length > 1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
nodes.each do |node|
|
|
137
|
+
strong_connect.call(node) unless indices.key?(node)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sccs
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :find_sccs
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
require_relative "hmac"
|
|
8
|
+
require_relative "refs"
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
require_relative "types"
|
|
11
|
+
require_relative "payload_topo"
|
|
12
|
+
require_relative "schema"
|
|
13
|
+
|
|
14
|
+
module Autonoma
|
|
15
|
+
module Handler
|
|
16
|
+
PROTOCOL_VERSION = begin
|
|
17
|
+
File.read(File.expand_path("../../../../protocol/version.txt", __dir__)).strip
|
|
18
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
19
|
+
"1.0"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
TOKEN_RE = /\{\{\s*([^{}]+?)\s*\}\}/
|
|
23
|
+
CYCLE_RE = /\Acycle\((.*)\)\z/
|
|
24
|
+
|
|
25
|
+
def self.handle_request(config, req)
|
|
26
|
+
if config.shared_secret == config.signing_secret
|
|
27
|
+
raise Errors.same_secrets
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless config.allow_production
|
|
31
|
+
env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || ENV["ENV"]
|
|
32
|
+
raise Errors.production_blocked if env == "production"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
signature = req.headers["x-signature"] || req.headers["X-Signature"] || ""
|
|
36
|
+
|
|
37
|
+
unless Hmac.verify_signature(req.body, signature, config.shared_secret)
|
|
38
|
+
raise Errors.invalid_signature
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
body = JSON.parse(req.body)
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
raise Errors.invalid_body("invalid JSON")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
action = body["action"]
|
|
48
|
+
raise Errors.invalid_body("missing action") unless action
|
|
49
|
+
|
|
50
|
+
case action
|
|
51
|
+
when "discover"
|
|
52
|
+
handle_discover(config)
|
|
53
|
+
when "up"
|
|
54
|
+
handle_up(config, body)
|
|
55
|
+
when "down"
|
|
56
|
+
handle_down(config, body)
|
|
57
|
+
else
|
|
58
|
+
raise Errors.unknown_action(action)
|
|
59
|
+
end
|
|
60
|
+
rescue AutonomaError => e
|
|
61
|
+
HandlerResponse.new(status: e.status, body: { "error" => e.message, "code" => e.code })
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
HandlerResponse.new(status: 500, body: { "error" => e.message, "code" => "INTERNAL_ERROR" })
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# -----------------------------------------------------------------------
|
|
67
|
+
# discover
|
|
68
|
+
# -----------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def self.handle_discover(config)
|
|
71
|
+
schema = Schema.build_schema_from_factories(config.factories || {}, config.scope_field)
|
|
72
|
+
HandlerResponse.new(
|
|
73
|
+
status: 200,
|
|
74
|
+
body: build_sdk_meta(config).merge("schema" => Schema.schema_to_wire(schema))
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# -----------------------------------------------------------------------
|
|
79
|
+
# up
|
|
80
|
+
# -----------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def self.handle_up(config, body)
|
|
83
|
+
create = body["create"]
|
|
84
|
+
raise Errors.invalid_body('missing "create" in request body') unless create
|
|
85
|
+
|
|
86
|
+
test_run_id = body["testRunId"] || SecureRandom.uuid
|
|
87
|
+
|
|
88
|
+
factories = config.factories || {}
|
|
89
|
+
if factories.empty?
|
|
90
|
+
raise Errors.invalid_body(
|
|
91
|
+
"no factories registered -- every model in `create` must have a factory."
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
tree = PayloadTopo.resolve_payload_tree(create)
|
|
96
|
+
|
|
97
|
+
refs = {}
|
|
98
|
+
id_map = {}
|
|
99
|
+
|
|
100
|
+
# Track per-model run index for {{index}} / {{cycle()}} substitution.
|
|
101
|
+
model_index = Hash.new(0)
|
|
102
|
+
|
|
103
|
+
tree.ops.each do |op|
|
|
104
|
+
model = op.model
|
|
105
|
+
factory = factories[model]
|
|
106
|
+
if factory.nil?
|
|
107
|
+
raise Errors.invalid_body(
|
|
108
|
+
"no factory registered for model \"#{model}\". " \
|
|
109
|
+
"Register one with define_factory(...) and add it to HandlerConfig factories."
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
idx = model_index[model]
|
|
114
|
+
model_index[model] = idx + 1
|
|
115
|
+
|
|
116
|
+
# Substitute built-in tokens then swap temp ids for real ids.
|
|
117
|
+
resolved = resolve_tokens(op.fields, test_run_id, idx)
|
|
118
|
+
resolved = swap_temp_ids(resolved, id_map)
|
|
119
|
+
|
|
120
|
+
# Validate through the factory's input_fields.
|
|
121
|
+
validated = Schema.validate_input(resolved, factory.input_fields)
|
|
122
|
+
|
|
123
|
+
ctx = FactoryContext.new(
|
|
124
|
+
refs: refs,
|
|
125
|
+
scenario_name: test_run_id,
|
|
126
|
+
test_run_id: test_run_id
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
record = factory.create.call(validated, ctx)
|
|
130
|
+
|
|
131
|
+
# Normalise to hash if needed.
|
|
132
|
+
if record.respond_to?(:to_h) && !record.is_a?(Hash)
|
|
133
|
+
record = record.to_h
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
if !record.is_a?(Hash) || record["id"].nil?
|
|
137
|
+
raise AutonomaError.new(
|
|
138
|
+
"Factory for \"#{model}\" must return a record hash with \"id\"",
|
|
139
|
+
"FACTORY_MISSING_PK",
|
|
140
|
+
500
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
refs[model] = (refs[model] || []) + [record]
|
|
145
|
+
id_map[op.temp_id] = record["id"]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Auth callback gets the first User (case-insensitive on model name).
|
|
149
|
+
auth_user = find_first_user(refs)
|
|
150
|
+
scope_value = detect_scope_value(refs, config.scope_field) || test_run_id
|
|
151
|
+
auth_context = AuthContext.new(scope_value: scope_value, refs: refs)
|
|
152
|
+
auth = config.auth.call(auth_user, auth_context)
|
|
153
|
+
|
|
154
|
+
if config.after_up
|
|
155
|
+
hook_ctx = HookContext.new(scenario_name: scope_value, refs: refs)
|
|
156
|
+
auth = config.after_up.call(hook_ctx, auth)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
refs_token = Refs.sign_refs(
|
|
160
|
+
{
|
|
161
|
+
"refs" => refs,
|
|
162
|
+
"testRunId" => scope_value,
|
|
163
|
+
"environment" => "",
|
|
164
|
+
"aliasDependencies" => tree.alias_dependencies,
|
|
165
|
+
"aliasOwnerModel" => tree.alias_owner_model
|
|
166
|
+
},
|
|
167
|
+
config.signing_secret
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
HandlerResponse.new(
|
|
171
|
+
status: 200,
|
|
172
|
+
body: build_sdk_meta(config).merge(
|
|
173
|
+
"auth" => auth,
|
|
174
|
+
"refs" => refs,
|
|
175
|
+
"refsToken" => refs_token
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# -----------------------------------------------------------------------
|
|
181
|
+
# down
|
|
182
|
+
# -----------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def self.handle_down(config, body)
|
|
185
|
+
refs_token = body["refsToken"]
|
|
186
|
+
raise Errors.invalid_body("missing refsToken") unless refs_token
|
|
187
|
+
|
|
188
|
+
begin
|
|
189
|
+
payload = Refs.verify_refs(refs_token, config.signing_secret)
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
raise Errors.invalid_refs_token(e.message)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
refs = payload["refs"] || {}
|
|
195
|
+
test_run_id = payload["testRunId"] || ""
|
|
196
|
+
alias_deps = payload["aliasDependencies"] || {}
|
|
197
|
+
alias_owner_model = payload["aliasOwnerModel"] || {}
|
|
198
|
+
|
|
199
|
+
if config.before_down
|
|
200
|
+
hook_ctx = HookContext.new(scenario_name: test_run_id, refs: refs)
|
|
201
|
+
config.before_down.call(hook_ctx)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
factories = config.factories || {}
|
|
205
|
+
teardown_order = PayloadTopo.compute_teardown_order(refs, alias_deps, alias_owner_model)
|
|
206
|
+
|
|
207
|
+
teardown_order.each do |model|
|
|
208
|
+
factory = factories[model]
|
|
209
|
+
next if factory.nil? || factory.teardown.nil?
|
|
210
|
+
|
|
211
|
+
records = refs[model] || []
|
|
212
|
+
ctx = FactoryContext.new(
|
|
213
|
+
refs: refs,
|
|
214
|
+
scenario_name: test_run_id,
|
|
215
|
+
test_run_id: test_run_id
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
records.reverse_each do |record|
|
|
219
|
+
factory.teardown.call(record, ctx)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
HandlerResponse.new(status: 200, body: build_sdk_meta(config).merge("ok" => true))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# -----------------------------------------------------------------------
|
|
227
|
+
# helpers
|
|
228
|
+
# -----------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def self.build_sdk_meta(config)
|
|
231
|
+
sdk = config.sdk || {}
|
|
232
|
+
{
|
|
233
|
+
"version" => PROTOCOL_VERSION,
|
|
234
|
+
"sdk" => {
|
|
235
|
+
"language" => "ruby",
|
|
236
|
+
"orm" => sdk["orm"] || sdk[:orm] || "unknown",
|
|
237
|
+
"server" => sdk["server"] || sdk[:server] || "unknown"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Substitute built-in tokens in field values: {{testRunId}}, {{index}},
|
|
243
|
+
# {{cycle(a,b,c)}}. Raises AutonomaError(UNRESOLVED_TOKEN) for any other
|
|
244
|
+
# {{token}}.
|
|
245
|
+
def self.resolve_tokens(value, test_run_id, index)
|
|
246
|
+
case value
|
|
247
|
+
when String
|
|
248
|
+
value.gsub(TOKEN_RE) do
|
|
249
|
+
token = Regexp.last_match(1).strip
|
|
250
|
+
if token == "testRunId"
|
|
251
|
+
test_run_id
|
|
252
|
+
elsif token == "index"
|
|
253
|
+
index.to_s
|
|
254
|
+
elsif (m = CYCLE_RE.match(token))
|
|
255
|
+
parts = m[1].split(",").map { |p| p.strip.gsub(/\A['"]|['"]\z/, "") }
|
|
256
|
+
parts.empty? ? "" : parts[index % parts.length]
|
|
257
|
+
else
|
|
258
|
+
raise AutonomaError.new(
|
|
259
|
+
"Unresolved token: {{#{token}}}",
|
|
260
|
+
"UNRESOLVED_TOKEN",
|
|
261
|
+
400
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
when Array
|
|
266
|
+
value.map { |v| resolve_tokens(v, test_run_id, index) }
|
|
267
|
+
when Hash
|
|
268
|
+
value.each_with_object({}) { |(k, v), out| out[k] = resolve_tokens(v, test_run_id, index) }
|
|
269
|
+
else
|
|
270
|
+
value
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Replace any __temp_* placeholder string with its real id.
|
|
275
|
+
def self.swap_temp_ids(value, id_map)
|
|
276
|
+
case value
|
|
277
|
+
when String
|
|
278
|
+
value.start_with?("__temp_") ? (id_map[value] || value) : value
|
|
279
|
+
when Hash
|
|
280
|
+
value.each_with_object({}) { |(k, v), out| out[k] = swap_temp_ids(v, id_map) }
|
|
281
|
+
when Array
|
|
282
|
+
value.map { |v| swap_temp_ids(v, id_map) }
|
|
283
|
+
else
|
|
284
|
+
value
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def self.find_first_user(refs)
|
|
289
|
+
refs.each do |model, records|
|
|
290
|
+
normalized = model.downcase
|
|
291
|
+
return records.first if (normalized == "user" || normalized == "users") && records.any?
|
|
292
|
+
end
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def self.detect_scope_value(refs, scope_field)
|
|
297
|
+
scope_normalized = scope_field.delete("_").downcase
|
|
298
|
+
refs.each_value do |records|
|
|
299
|
+
records.each do |record|
|
|
300
|
+
record.each do |key, value|
|
|
301
|
+
return value if key.delete("_").downcase == scope_normalized && value.is_a?(String)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private_class_method :build_sdk_meta, :handle_discover, :handle_up,
|
|
309
|
+
:handle_down, :find_first_user, :detect_scope_value,
|
|
310
|
+
:resolve_tokens, :swap_temp_ids
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Autonoma
|
|
6
|
+
# Constant-time string comparison to prevent timing attacks.
|
|
7
|
+
# Shared by Hmac and Refs modules.
|
|
8
|
+
def self.secure_compare(a, b)
|
|
9
|
+
return false unless a.bytesize == b.bytesize
|
|
10
|
+
|
|
11
|
+
l = a.unpack("C*")
|
|
12
|
+
r = b.unpack("C*")
|
|
13
|
+
result = 0
|
|
14
|
+
l.each_with_index { |v, i| result |= v ^ r[i] }
|
|
15
|
+
result.zero?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module Hmac
|
|
19
|
+
# Sign a body string with a secret using HMAC-SHA256. Returns 64-char lowercase hex.
|
|
20
|
+
def self.sign_body(body, secret)
|
|
21
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, body)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Verify a signature using constant-time comparison.
|
|
25
|
+
def self.verify_signature(body, signature, secret)
|
|
26
|
+
expected = sign_body(body, secret)
|
|
27
|
+
return false unless expected.length == signature.length
|
|
28
|
+
|
|
29
|
+
Autonoma.secure_compare(expected, signature)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Autonoma
|
|
4
|
+
module PayloadTopo
|
|
5
|
+
# Output of resolve_payload_tree.
|
|
6
|
+
class ResolvedTree
|
|
7
|
+
attr_accessor :ops, :aliases, :alias_owner_model, :alias_dependencies
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@ops = []
|
|
11
|
+
@aliases = {}
|
|
12
|
+
@alias_owner_model = {}
|
|
13
|
+
@alias_dependencies = {}
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RESERVED_KEYS = Set.new(%w[_alias _ref]).freeze
|
|
18
|
+
|
|
19
|
+
# Walk a field value tree and append every _ref alias found.
|
|
20
|
+
def self.collect_refs(value, out)
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
ref = value["_ref"]
|
|
24
|
+
if ref.is_a?(String)
|
|
25
|
+
out << ref
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
value.each_value { |v| collect_refs(v, out) }
|
|
29
|
+
when Array
|
|
30
|
+
value.each { |v| collect_refs(v, out) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Replace each {"_ref": alias} with its temp id.
|
|
35
|
+
def self.resolve_refs(value, alias_to_temp_id)
|
|
36
|
+
case value
|
|
37
|
+
when Hash
|
|
38
|
+
ref = value["_ref"]
|
|
39
|
+
if ref.is_a?(String)
|
|
40
|
+
real = alias_to_temp_id[ref]
|
|
41
|
+
return real.nil? ? value : real
|
|
42
|
+
end
|
|
43
|
+
value.each_with_object({}) { |(k, v), out| out[k] = resolve_refs(v, alias_to_temp_id) }
|
|
44
|
+
when Array
|
|
45
|
+
value.map { |v| resolve_refs(v, alias_to_temp_id) }
|
|
46
|
+
else
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Topo-sort a create payload into an ordered list of CreateOp.
|
|
52
|
+
def self.resolve_payload_tree(create)
|
|
53
|
+
unless create.is_a?(Hash)
|
|
54
|
+
raise Errors.invalid_body("`create` must be an object keyed by model name")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# First pass: assign temp ids and collect alias declarations.
|
|
58
|
+
raw_entries = [] # [model, temp_id, entity, alias_name]
|
|
59
|
+
counter = 0
|
|
60
|
+
aliases = {}
|
|
61
|
+
alias_owner_model = {}
|
|
62
|
+
|
|
63
|
+
create.each do |model, entities|
|
|
64
|
+
unless entities.is_a?(Array)
|
|
65
|
+
raise Errors.invalid_body(
|
|
66
|
+
"`create.#{model}` must be a list of entity objects, got #{entities.class.name}"
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
entities.each do |entity|
|
|
71
|
+
unless entity.is_a?(Hash)
|
|
72
|
+
raise Errors.invalid_body(
|
|
73
|
+
"`create.#{model}` entries must be objects, got #{entity.class.name}"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
temp_id = "__temp_#{model}_#{counter}"
|
|
78
|
+
counter += 1
|
|
79
|
+
|
|
80
|
+
alias_name = entity["_alias"]
|
|
81
|
+
if alias_name.is_a?(String)
|
|
82
|
+
if aliases.key?(alias_name)
|
|
83
|
+
raise Errors.invalid_body("duplicate _alias \"#{alias_name}\"")
|
|
84
|
+
end
|
|
85
|
+
aliases[alias_name] = temp_id
|
|
86
|
+
alias_owner_model[alias_name] = model
|
|
87
|
+
elsif !alias_name.nil?
|
|
88
|
+
raise Errors.invalid_body('"_alias" must be a string')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
raw_entries << [model, temp_id, entity, alias_name.is_a?(String) ? alias_name : nil]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Second pass: collect dependencies and strip reserved keys.
|
|
96
|
+
deps_by_temp_id = {}
|
|
97
|
+
fields_by_temp_id = {}
|
|
98
|
+
model_by_temp_id = {}
|
|
99
|
+
alias_by_temp_id = {}
|
|
100
|
+
|
|
101
|
+
raw_entries.each do |model, temp_id, entity, alias_name|
|
|
102
|
+
deps = []
|
|
103
|
+
cleaned = {}
|
|
104
|
+
|
|
105
|
+
entity.each do |key, value|
|
|
106
|
+
next if RESERVED_KEYS.include?(key)
|
|
107
|
+
|
|
108
|
+
collect_refs(value, deps)
|
|
109
|
+
cleaned[key] = resolve_refs(value, aliases)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
unknown = deps.select { |a| !aliases.key?(a) }.uniq.sort
|
|
113
|
+
unless unknown.empty?
|
|
114
|
+
raise Errors.invalid_body(
|
|
115
|
+
"`create.#{model}` references unknown alias(es): #{unknown.join(", ")}"
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
deps_by_temp_id[temp_id] = deps
|
|
120
|
+
fields_by_temp_id[temp_id] = cleaned
|
|
121
|
+
model_by_temp_id[temp_id] = model
|
|
122
|
+
alias_by_temp_id[temp_id] = alias_name
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Build the temp_id graph and topo-sort.
|
|
126
|
+
in_degree = {}
|
|
127
|
+
raw_entries.each { |_, temp_id, _, _| in_degree[temp_id] = 0 }
|
|
128
|
+
|
|
129
|
+
edges = Hash.new { |h, k| h[k] = [] }
|
|
130
|
+
|
|
131
|
+
deps_by_temp_id.each do |temp_id, deps|
|
|
132
|
+
seen = Set.new
|
|
133
|
+
deps.each do |dep_alias|
|
|
134
|
+
dep_temp_id = aliases[dep_alias]
|
|
135
|
+
next if dep_temp_id == temp_id || seen.include?(dep_temp_id)
|
|
136
|
+
|
|
137
|
+
seen.add(dep_temp_id)
|
|
138
|
+
edges[dep_temp_id] << temp_id
|
|
139
|
+
in_degree[temp_id] += 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Kahn's, preserving payload order as the stable tie-breaker.
|
|
144
|
+
payload_order = {}
|
|
145
|
+
raw_entries.each_with_index { |(_, temp_id, _, _), idx| payload_order[temp_id] = idx }
|
|
146
|
+
|
|
147
|
+
ready = in_degree.select { |_, d| d == 0 }.keys.sort_by { |t| payload_order[t] }
|
|
148
|
+
sorted_temp_ids = []
|
|
149
|
+
|
|
150
|
+
until ready.empty?
|
|
151
|
+
tid = ready.shift
|
|
152
|
+
sorted_temp_ids << tid
|
|
153
|
+
(edges[tid] || []).each do |nxt|
|
|
154
|
+
in_degree[nxt] -= 1
|
|
155
|
+
ready << nxt if in_degree[nxt] == 0
|
|
156
|
+
end
|
|
157
|
+
ready.sort_by! { |t| payload_order[t] }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if sorted_temp_ids.length != payload_order.length
|
|
161
|
+
cycle = in_degree.select { |_, d| d > 0 }.keys.sort_by { |t| payload_order[t] }
|
|
162
|
+
cycle_models = cycle.map { |t| model_by_temp_id[t] }.join(", ")
|
|
163
|
+
raise Errors.invalid_body("cycle detected in _alias/_ref graph: #{cycle_models}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Build the result.
|
|
167
|
+
tree = ResolvedTree.new
|
|
168
|
+
tree.aliases = aliases
|
|
169
|
+
tree.alias_owner_model = alias_owner_model
|
|
170
|
+
tree.alias_dependencies = aliases.each_with_object({}) do |(alias_name, temp_id), h|
|
|
171
|
+
h[alias_name] = deps_by_temp_id[temp_id] || []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sorted_temp_ids.each do |tid|
|
|
175
|
+
tree.ops << CreateOp.new(
|
|
176
|
+
model: model_by_temp_id[tid],
|
|
177
|
+
fields: fields_by_temp_id[tid],
|
|
178
|
+
temp_id: tid
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
tree
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Order models for teardown.
|
|
186
|
+
def self.compute_teardown_order(refs, alias_dependencies, alias_owner_model)
|
|
187
|
+
models = refs.keys
|
|
188
|
+
|
|
189
|
+
if alias_dependencies.nil? || alias_dependencies.empty? ||
|
|
190
|
+
alias_owner_model.nil? || alias_owner_model.empty?
|
|
191
|
+
return models.reverse
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Build model-level dependency graph.
|
|
195
|
+
model_deps = models.each_with_object({}) { |m, h| h[m] = Set.new }
|
|
196
|
+
|
|
197
|
+
alias_dependencies.each do |alias_name, deps|
|
|
198
|
+
owner = alias_owner_model[alias_name]
|
|
199
|
+
next if owner.nil? || !model_deps.key?(owner)
|
|
200
|
+
|
|
201
|
+
deps.each do |dep_alias|
|
|
202
|
+
dep_model = alias_owner_model[dep_alias]
|
|
203
|
+
next if dep_model.nil? || dep_model == owner || !model_deps.key?(dep_model)
|
|
204
|
+
|
|
205
|
+
model_deps[owner].add(dep_model)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Kahn's over models.
|
|
210
|
+
in_degree = models.each_with_object({}) { |m, h| h[m] = 0 }
|
|
211
|
+
adj = Hash.new { |h, k| h[k] = [] }
|
|
212
|
+
|
|
213
|
+
model_deps.each do |owner, deps|
|
|
214
|
+
deps.each do |dep_model|
|
|
215
|
+
adj[dep_model] << owner
|
|
216
|
+
in_degree[owner] += 1
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
payload_order = models.each_with_index.to_h
|
|
221
|
+
ready = in_degree.select { |_, d| d == 0 }.keys.sort_by { |m| payload_order[m] }
|
|
222
|
+
up_order = []
|
|
223
|
+
|
|
224
|
+
until ready.empty?
|
|
225
|
+
m = ready.shift
|
|
226
|
+
up_order << m
|
|
227
|
+
(adj[m] || []).each do |nxt|
|
|
228
|
+
in_degree[nxt] -= 1
|
|
229
|
+
ready << nxt if in_degree[nxt] == 0
|
|
230
|
+
end
|
|
231
|
+
ready.sort_by! { |m2| payload_order[m2] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Fall back to reversed insertion order if cycle detected (shouldn't happen).
|
|
235
|
+
return models.reverse if up_order.length != models.length
|
|
236
|
+
|
|
237
|
+
up_order.reverse
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private_class_method :collect_refs, :resolve_refs
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "bigdecimal"
|
|
7
|
+
require "date"
|
|
8
|
+
require "time"
|
|
9
|
+
|
|
10
|
+
module Autonoma
|
|
11
|
+
module Refs
|
|
12
|
+
# Sign a refs payload into a 3-part token string.
|
|
13
|
+
def self.sign_refs(payload, secret)
|
|
14
|
+
header = base64url_encode(JSON.generate({ alg: "HS256", typ: "REFS" }))
|
|
15
|
+
body = base64url_encode(JSON.generate(make_json_safe(payload)))
|
|
16
|
+
signature = hmac_sign("#{header}.#{body}", secret)
|
|
17
|
+
"#{header}.#{body}.#{signature}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Verify and decode a refs token. Returns the payload hash or raises.
|
|
21
|
+
def self.verify_refs(token, secret)
|
|
22
|
+
parts = token.split(".")
|
|
23
|
+
raise "malformed token" unless parts.length == 3
|
|
24
|
+
|
|
25
|
+
header, body, signature = parts
|
|
26
|
+
expected = hmac_sign("#{header}.#{body}", secret)
|
|
27
|
+
|
|
28
|
+
raise "signature mismatch" unless Autonoma.secure_compare(expected, signature)
|
|
29
|
+
|
|
30
|
+
JSON.parse(base64url_decode(body))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.base64url_encode(data)
|
|
34
|
+
data = data.encode("UTF-8") if data.is_a?(String)
|
|
35
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.base64url_decode(data)
|
|
39
|
+
# Add padding back
|
|
40
|
+
padding = 4 - (data.length % 4)
|
|
41
|
+
data += "=" * padding if padding != 4
|
|
42
|
+
Base64.urlsafe_decode64(data)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.hmac_sign(data, secret)
|
|
46
|
+
sig = OpenSSL::HMAC.digest("SHA256", secret, data)
|
|
47
|
+
Base64.urlsafe_encode64(sig, padding: false)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Recursively convert non-JSON-safe types (Time, DateTime, BigDecimal, etc.)
|
|
51
|
+
# to strings so that JSON.generate does not raise.
|
|
52
|
+
def self.make_json_safe(obj)
|
|
53
|
+
case obj
|
|
54
|
+
when Hash
|
|
55
|
+
obj.transform_values { |v| make_json_safe(v) }
|
|
56
|
+
when Array
|
|
57
|
+
obj.map { |v| make_json_safe(v) }
|
|
58
|
+
when Time, DateTime
|
|
59
|
+
obj.iso8601(3)
|
|
60
|
+
when Date
|
|
61
|
+
obj.iso8601
|
|
62
|
+
when BigDecimal
|
|
63
|
+
obj.to_s("F")
|
|
64
|
+
when Symbol
|
|
65
|
+
obj.to_s
|
|
66
|
+
else
|
|
67
|
+
obj
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private_class_method :base64url_encode, :base64url_decode, :hmac_sign
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Autonoma
|
|
4
|
+
module Schema
|
|
5
|
+
VALID_TYPES = Set.new(%w[string integer number boolean timestamp date uuid json]).freeze
|
|
6
|
+
|
|
7
|
+
# Map a Ruby type string to the SDK's coarse type string.
|
|
8
|
+
def self.field_type_from_class(type_str)
|
|
9
|
+
return "string" if type_str.nil?
|
|
10
|
+
|
|
11
|
+
normalized = type_str.to_s.downcase
|
|
12
|
+
VALID_TYPES.include?(normalized) ? normalized : "string"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Convert CamelCase to snake_case for cosmetic tableName.
|
|
16
|
+
def self.camel_to_snake(name)
|
|
17
|
+
out = []
|
|
18
|
+
name.each_char.with_index do |ch, i|
|
|
19
|
+
if ch =~ /[A-Z]/ && i > 0 && name[i - 1] !~ /[A-Z]/
|
|
20
|
+
out << "_"
|
|
21
|
+
end
|
|
22
|
+
out << ch.downcase
|
|
23
|
+
end
|
|
24
|
+
out.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Build field list from input_fields array.
|
|
28
|
+
# Each entry is a hash with :name, :type, :required keys.
|
|
29
|
+
def self.fields_from_input_fields(input_fields)
|
|
30
|
+
fields = [
|
|
31
|
+
FieldInfo.new(
|
|
32
|
+
name: "id",
|
|
33
|
+
type: "string",
|
|
34
|
+
is_required: false,
|
|
35
|
+
is_id: true,
|
|
36
|
+
has_default: true
|
|
37
|
+
)
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
(input_fields || []).each do |f|
|
|
41
|
+
name = f[:name] || f["name"]
|
|
42
|
+
type = f[:type] || f["type"]
|
|
43
|
+
required = f[:required].nil? ? f["required"] : f[:required]
|
|
44
|
+
|
|
45
|
+
fields << FieldInfo.new(
|
|
46
|
+
name: name.to_s,
|
|
47
|
+
type: field_type_from_class(type),
|
|
48
|
+
is_required: !!required,
|
|
49
|
+
is_id: false,
|
|
50
|
+
has_default: !required
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
fields
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build the SDK's discover-time schema from registered factories.
|
|
58
|
+
def self.build_schema_from_factories(factories, scope_field)
|
|
59
|
+
models = []
|
|
60
|
+
|
|
61
|
+
(factories || {}).each do |entity, factory|
|
|
62
|
+
if factory.input_fields.nil? || factory.input_fields.empty?
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"Factory \"#{entity}\" has no input_fields. " \
|
|
65
|
+
"Every factory must declare input_fields in define_factory(...)."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
models << ModelInfo.new(
|
|
69
|
+
name: entity,
|
|
70
|
+
table_name: camel_to_snake(entity),
|
|
71
|
+
fields: fields_from_input_fields(factory.input_fields)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
SchemaInfo.new(
|
|
76
|
+
models: models,
|
|
77
|
+
edges: [],
|
|
78
|
+
relations: [],
|
|
79
|
+
scope_field: scope_field
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Serialise a SchemaInfo to the JSON shape the dashboard expects.
|
|
84
|
+
def self.schema_to_wire(schema)
|
|
85
|
+
{
|
|
86
|
+
"models" => schema.models.map do |m|
|
|
87
|
+
{
|
|
88
|
+
"name" => m.name,
|
|
89
|
+
"tableName" => m.table_name,
|
|
90
|
+
"fields" => m.fields.map do |f|
|
|
91
|
+
{
|
|
92
|
+
"name" => f.name,
|
|
93
|
+
"type" => f.type,
|
|
94
|
+
"isRequired" => f.is_required,
|
|
95
|
+
"isId" => f.is_id,
|
|
96
|
+
"hasDefault" => f.has_default
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
}
|
|
100
|
+
end,
|
|
101
|
+
"edges" => schema.edges.map do |e|
|
|
102
|
+
{
|
|
103
|
+
"from" => e.from_model,
|
|
104
|
+
"to" => e.to_model,
|
|
105
|
+
"localField" => e.local_field,
|
|
106
|
+
"foreignField" => e.foreign_field,
|
|
107
|
+
"nullable" => e.nullable
|
|
108
|
+
}
|
|
109
|
+
end,
|
|
110
|
+
"relations" => schema.relations.map do |r|
|
|
111
|
+
{
|
|
112
|
+
"parentModel" => r.parent_model,
|
|
113
|
+
"childModel" => r.child_model,
|
|
114
|
+
"parentField" => r.parent_field,
|
|
115
|
+
"childField" => r.child_field
|
|
116
|
+
}
|
|
117
|
+
end,
|
|
118
|
+
"scopeField" => schema.scope_field
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Validate input fields hash: strip unknown keys, check required fields.
|
|
123
|
+
def self.validate_input(fields, input_fields)
|
|
124
|
+
known_names = Set.new
|
|
125
|
+
required_names = []
|
|
126
|
+
|
|
127
|
+
(input_fields || []).each do |f|
|
|
128
|
+
name = (f[:name] || f["name"]).to_s
|
|
129
|
+
known_names.add(name)
|
|
130
|
+
req = f[:required].nil? ? f["required"] : f[:required]
|
|
131
|
+
required_names << name if req
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Strip unknown keys
|
|
135
|
+
validated = fields.select { |k, _| known_names.include?(k.to_s) }
|
|
136
|
+
|
|
137
|
+
# Check required fields
|
|
138
|
+
missing = required_names.select { |name| !validated.key?(name) && !validated.key?(name.to_sym) }
|
|
139
|
+
unless missing.empty?
|
|
140
|
+
raise Errors.invalid_body("missing required fields: #{missing.join(", ")}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
validated
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private_class_method :camel_to_snake, :fields_from_input_fields
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Autonoma
|
|
4
|
+
FieldInfo = Struct.new(:name, :type, :is_required, :is_id, :has_default, keyword_init: true)
|
|
5
|
+
ModelInfo = Struct.new(:name, :table_name, :fields, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
FKEdge = Struct.new(:from_model, :to_model, :local_field, :foreign_field, :nullable, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
SchemaRelation = Struct.new(:parent_model, :child_model, :parent_field, :child_field, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
SchemaInfo = Struct.new(:models, :edges, :relations, :scope_field, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
CreateOp = Struct.new(:model, :fields, :temp_id, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
HandlerRequest = Struct.new(:body, :headers, keyword_init: true) do
|
|
16
|
+
def initialize(body:, headers: {})
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
HandlerResponse = Struct.new(:status, :body, keyword_init: true)
|
|
22
|
+
|
|
23
|
+
HookContext = Struct.new(:scenario_name, :refs, keyword_init: true)
|
|
24
|
+
AuthContext = Struct.new(:scope_value, :refs, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
FactoryContext = Struct.new(:refs, :scenario_name, :test_run_id, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
FactoryDefinition = Struct.new(:create, :teardown, :input_fields, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
HandlerConfig = Struct.new(
|
|
31
|
+
:scope_field,
|
|
32
|
+
:shared_secret,
|
|
33
|
+
:signing_secret,
|
|
34
|
+
:allow_production,
|
|
35
|
+
:auth,
|
|
36
|
+
:sdk,
|
|
37
|
+
:before_down,
|
|
38
|
+
:after_up,
|
|
39
|
+
:factories,
|
|
40
|
+
keyword_init: true
|
|
41
|
+
) do
|
|
42
|
+
def initialize(scope_field:, shared_secret:, signing_secret:, auth:,
|
|
43
|
+
allow_production: false, sdk: nil,
|
|
44
|
+
before_down: nil, after_up: nil, factories: nil)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/autonoma.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "autonoma/errors"
|
|
4
|
+
require_relative "autonoma/types"
|
|
5
|
+
require_relative "autonoma/hmac"
|
|
6
|
+
require_relative "autonoma/refs"
|
|
7
|
+
require_relative "autonoma/graph"
|
|
8
|
+
require_relative "autonoma/fingerprint"
|
|
9
|
+
require_relative "autonoma/payload_topo"
|
|
10
|
+
require_relative "autonoma/schema"
|
|
11
|
+
require_relative "autonoma/factory"
|
|
12
|
+
require_relative "autonoma/handler"
|
|
13
|
+
|
|
14
|
+
module Autonoma
|
|
15
|
+
VERSION = "0.2.0"
|
|
16
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "autonoma"
|
|
4
|
+
|
|
5
|
+
module AutonomaRails
|
|
6
|
+
# Rails controller mixin for handling Autonoma protocol requests.
|
|
7
|
+
#
|
|
8
|
+
# Usage in a Rails controller:
|
|
9
|
+
#
|
|
10
|
+
# class AutonomaController < ApplicationController
|
|
11
|
+
# include AutonomaRails::Handler
|
|
12
|
+
#
|
|
13
|
+
# skip_before_action :verify_authenticity_token
|
|
14
|
+
#
|
|
15
|
+
# def handle
|
|
16
|
+
# autonoma_handle(autonoma_config)
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# private
|
|
20
|
+
#
|
|
21
|
+
# def autonoma_config
|
|
22
|
+
# @autonoma_config ||= Autonoma::HandlerConfig.new(
|
|
23
|
+
# scope_field: "organizationId",
|
|
24
|
+
# shared_secret: ENV["AUTONOMA_SHARED_SECRET"],
|
|
25
|
+
# signing_secret: ENV["AUTONOMA_SIGNING_SECRET"],
|
|
26
|
+
# auth: ->(user, ctx) { { "token" => "..." } },
|
|
27
|
+
# factories: { "User" => Autonoma::Factory.define_factory(...) }
|
|
28
|
+
# )
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
module Handler
|
|
33
|
+
def autonoma_handle(config)
|
|
34
|
+
enriched = config.dup
|
|
35
|
+
sdk = (enriched.sdk || {}).merge("server" => "rails")
|
|
36
|
+
enriched.sdk = sdk
|
|
37
|
+
|
|
38
|
+
body_str = request.raw_post
|
|
39
|
+
headers = request.headers.to_h
|
|
40
|
+
.select { |k, _| k.is_a?(String) }
|
|
41
|
+
.transform_keys(&:downcase)
|
|
42
|
+
|
|
43
|
+
req = Autonoma::HandlerRequest.new(body: body_str, headers: headers)
|
|
44
|
+
result = Autonoma::Handler.handle_request(enriched, req)
|
|
45
|
+
|
|
46
|
+
render json: Autonoma::Refs.make_json_safe(result.body), status: result.status
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Rack middleware alternative for mounting Autonoma as a Rack app.
|
|
51
|
+
class Middleware
|
|
52
|
+
def initialize(app, config, path: "/api/autonoma")
|
|
53
|
+
@app = app
|
|
54
|
+
@config = enrich_config(config)
|
|
55
|
+
@path = path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call(env)
|
|
59
|
+
unless env["PATH_INFO"] == @path && env["REQUEST_METHOD"] == "POST"
|
|
60
|
+
return @app.call(env)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
body_str = env["rack.input"].read
|
|
64
|
+
env["rack.input"].rewind
|
|
65
|
+
|
|
66
|
+
headers = {}
|
|
67
|
+
env.each do |key, value|
|
|
68
|
+
if key.start_with?("HTTP_")
|
|
69
|
+
header_name = key[5..].downcase.tr("_", "-")
|
|
70
|
+
headers[header_name] = value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
headers["content-type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
|
|
74
|
+
|
|
75
|
+
req = Autonoma::HandlerRequest.new(body: body_str, headers: headers)
|
|
76
|
+
result = Autonoma::Handler.handle_request(@config, req)
|
|
77
|
+
|
|
78
|
+
[
|
|
79
|
+
result.status,
|
|
80
|
+
{ "Content-Type" => "application/json" },
|
|
81
|
+
[JSON.generate(Autonoma::Refs.make_json_safe(result.body))]
|
|
82
|
+
]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def enrich_config(config)
|
|
88
|
+
enriched = config.dup
|
|
89
|
+
sdk = (enriched.sdk || {}).merge("server" => "rails")
|
|
90
|
+
enriched.sdk = sdk
|
|
91
|
+
enriched
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: autonoma-ai
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Autonoma AI
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-26 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: Ruby SDK for the Autonoma Environment Factory. Handles HMAC verification,
|
|
42
|
+
JWT refs, template resolution, factory-driven entity creation, and scoped teardown.
|
|
43
|
+
email:
|
|
44
|
+
- eng@autonoma.ai
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- lib/autonoma.rb
|
|
50
|
+
- lib/autonoma/errors.rb
|
|
51
|
+
- lib/autonoma/factory.rb
|
|
52
|
+
- lib/autonoma/fingerprint.rb
|
|
53
|
+
- lib/autonoma/graph.rb
|
|
54
|
+
- lib/autonoma/handler.rb
|
|
55
|
+
- lib/autonoma/hmac.rb
|
|
56
|
+
- lib/autonoma/payload_topo.rb
|
|
57
|
+
- lib/autonoma/refs.rb
|
|
58
|
+
- lib/autonoma/schema.rb
|
|
59
|
+
- lib/autonoma/types.rb
|
|
60
|
+
- lib/autonoma_rails/server.rb
|
|
61
|
+
homepage: https://autonoma.ai
|
|
62
|
+
licenses:
|
|
63
|
+
- MIT
|
|
64
|
+
metadata:
|
|
65
|
+
homepage_uri: https://autonoma.ai
|
|
66
|
+
source_code_uri: https://github.com/Autonoma-AI/sdk
|
|
67
|
+
post_install_message:
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.1'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.0.3.1
|
|
83
|
+
signing_key:
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: Autonoma SDK — automate the Autonoma Environment Factory endpoint
|
|
86
|
+
test_files: []
|