ace-lab 0.1.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.
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Atoms
8
+ # Pure schema validation and normalization for ace-lab topology
9
+ # configuration (spec 8wq.t.1w4). Takes the ADR-022-resolved config hash
10
+ # and returns a normalized structure; raises
11
+ # Ace::Lab::InvalidConfigurationError on any violation (duplicate IDs,
12
+ # unknown project references, malformed capabilities, invalid defaults,
13
+ # unusable endpoints, unsupported schema version).
14
+ #
15
+ # Normalization: strings are stripped, capabilities are lowercased,
16
+ # binding state is lowercased. Unknown keys are ignored.
17
+ #
18
+ # Error messages use positional field locations (e.g.
19
+ # "topology.agents[0].project") and never echo configured values:
20
+ # validation runs before authorization, so messages must not disclose
21
+ # topology to unauthorized callers (review round 2, R2).
22
+ module TopologySchema
23
+ SCHEMA_VERSION = 1
24
+ ENDPOINT_KINDS = %w[http https].freeze
25
+ # Bindings are entry-typed: agents run in runtimes, services are
26
+ # service endpoints (review round 4, F1)
27
+ AGENT_BINDING_KINDS = %w[runtime].freeze
28
+ SERVICE_BINDING_KINDS = %w[service].freeze
29
+
30
+ class << self
31
+ # Validate and normalize the full lab configuration
32
+ #
33
+ # @param config [Hash] ADR-022-resolved configuration
34
+ # @return [Hash] Normalized configuration
35
+ # @raise [Ace::Lab::InvalidConfigurationError]
36
+ def normalize!(config)
37
+ raise invalid("configuration must be a mapping") unless config.is_a?(Hash)
38
+
39
+ version = config["schema_version"]
40
+ unless version == SCHEMA_VERSION
41
+ raise invalid("unsupported schema_version (expected #{SCHEMA_VERSION})")
42
+ end
43
+
44
+ topology = normalized_topology(config["topology"])
45
+ authorization = normalized_authorization(config["authorization"], topology)
46
+
47
+ {
48
+ "schema_version" => SCHEMA_VERSION,
49
+ "authorization" => authorization,
50
+ "topology" => topology
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ def normalized_topology(raw)
57
+ raise invalid("topology must be a mapping") unless raw.nil? || raw.is_a?(Hash)
58
+
59
+ raw ||= {}
60
+ projects = require_array(raw["projects"], "topology.projects").map.with_index do |entry, i|
61
+ normalized_project(entry, "topology.projects[#{i}]")
62
+ end
63
+ project_ids = projects.map { |p| p["id"] }
64
+ agents = require_array(raw["agents"], "topology.agents").map.with_index do |entry, i|
65
+ normalized_agent(entry, "topology.agents[#{i}]", project_ids)
66
+ end
67
+ services = require_array(raw["services"], "topology.services").map.with_index do |entry, i|
68
+ normalized_service(entry, "topology.services[#{i}]", project_ids)
69
+ end
70
+
71
+ ensure_globally_unique!(projects, agents, services)
72
+
73
+ {"projects" => projects, "agents" => agents, "services" => services}
74
+ end
75
+
76
+ def normalized_project(entry, context)
77
+ raise invalid("#{context} must be a mapping") unless entry.is_a?(Hash)
78
+
79
+ {
80
+ "id" => require_id(entry["id"], "#{context}.id"),
81
+ "label" => optional_string(entry["label"], "#{context}.label")
82
+ }
83
+ end
84
+
85
+ def normalized_agent(entry, context, project_ids)
86
+ raise invalid("#{context} must be a mapping") unless entry.is_a?(Hash)
87
+
88
+ id = require_id(entry["id"], "#{context}.id")
89
+ project = require_project_ref(entry["project"], "#{context}.project", project_ids)
90
+
91
+ {
92
+ "id" => id,
93
+ "project" => project,
94
+ "label" => optional_string(entry["label"], "#{context}.label"),
95
+ "role" => require_non_empty(entry["role"], "#{context}.role must be a non-empty string"),
96
+ "capabilities" => normalize_capabilities(entry["capabilities"], "#{context}.capabilities"),
97
+ "binding" => normalize_binding(entry["binding"], "#{context}.binding", AGENT_BINDING_KINDS)
98
+ }
99
+ end
100
+
101
+ def normalized_service(entry, context, project_ids)
102
+ raise invalid("#{context} must be a mapping") unless entry.is_a?(Hash)
103
+
104
+ id = require_id(entry["id"], "#{context}.id")
105
+ project = require_project_ref(entry["project"], "#{context}.project", project_ids)
106
+ capabilities = normalize_capabilities(entry["capabilities"], "#{context}.capabilities")
107
+
108
+ {
109
+ "id" => id,
110
+ "project" => project,
111
+ "label" => optional_string(entry["label"], "#{context}.label"),
112
+ "capabilities" => capabilities,
113
+ "default_for" => normalize_default_for(entry["default_for"], "#{context}.default_for", capabilities),
114
+ "endpoint" => normalize_endpoint(entry["endpoint"], "#{context}.endpoint"),
115
+ "binding" => normalize_binding(entry["binding"], "#{context}.binding", SERVICE_BINDING_KINDS)
116
+ }
117
+ end
118
+
119
+ def ensure_globally_unique!(projects, agents, services)
120
+ seen = Hash.new { |h, k| h[k] = [] }
121
+ projects.each_with_index { |e, i| seen[e["id"]] << "topology.projects[#{i}]" }
122
+ agents.each_with_index { |e, i| seen[e["id"]] << "topology.agents[#{i}]" }
123
+ services.each_with_index { |e, i| seen[e["id"]] << "topology.services[#{i}]" }
124
+
125
+ _id, locations = seen.find { |_key, list| list.length > 1 }
126
+ return unless locations
127
+
128
+ raise invalid("duplicate stable id: #{locations.join(" and ")} define the same id; " \
129
+ "stable IDs must be globally unique")
130
+ end
131
+
132
+ def normalized_authorization(raw, topology)
133
+ raise invalid("authorization must be a mapping") unless raw.nil? || raw.is_a?(Hash)
134
+
135
+ raw ||= {}
136
+ principals_raw = raw["principals"]
137
+ unless principals_raw.nil? || principals_raw.is_a?(Hash)
138
+ raise invalid("authorization.principals must be a mapping")
139
+ end
140
+
141
+ project_ids = topology["projects"].map { |p| p["id"] }
142
+ principals = (principals_raw || {}).map.with_index do |(principal, policy), i|
143
+ context = "authorization.principals[#{i}]"
144
+ name = require_non_empty(principal, "#{context} name must be a non-empty string")
145
+ raise invalid("#{context} policy must be a mapping") unless policy.is_a?(Hash)
146
+
147
+ allowed = require_array(policy["projects"], "#{context}.projects").map.with_index do |project, j|
148
+ unless project_ids.include?(project)
149
+ raise invalid("#{context}.projects[#{j}] references an unknown project")
150
+ end
151
+
152
+ project
153
+ end
154
+ [name, {"projects" => allowed.uniq}]
155
+ end
156
+
157
+ {"principals" => principals.to_h}
158
+ end
159
+
160
+ def require_id(value, context)
161
+ require_non_empty(value, "#{context} must be a non-empty string")
162
+ end
163
+
164
+ # Malformed collections must classify as invalid_configuration, not
165
+ # crash with NoMethodError/TypeError mid-iteration (review R2)
166
+ def require_array(value, context)
167
+ return [] if value.nil?
168
+ raise invalid("#{context} must be an array") unless value.is_a?(Array)
169
+
170
+ value
171
+ end
172
+
173
+ def require_project_ref(value, context, project_ids)
174
+ project = require_non_empty(value, "#{context} must be a non-empty string")
175
+ unless project_ids.include?(project)
176
+ raise invalid("#{context} references an unknown project")
177
+ end
178
+
179
+ project
180
+ end
181
+
182
+ def normalize_capabilities(value, context)
183
+ unless value.is_a?(Array)
184
+ raise invalid("#{context} must be a non-empty array of strings")
185
+ end
186
+
187
+ capabilities = value.map do |capability|
188
+ require_non_empty(capability, "#{context} entry must be a non-empty string").downcase
189
+ end
190
+ unless capabilities.any?
191
+ raise invalid("#{context} must be a non-empty array of strings")
192
+ end
193
+
194
+ capabilities.uniq
195
+ end
196
+
197
+ def normalize_default_for(value, context, capabilities)
198
+ return [] if value.nil?
199
+ unless value.is_a?(Array)
200
+ raise invalid("#{context} must be an array of capabilities")
201
+ end
202
+
203
+ value.map do |capability|
204
+ normalized = require_non_empty(capability, "#{context} entry must be a non-empty string").downcase
205
+ unless capabilities.include?(normalized)
206
+ raise invalid("#{context} entry does not match a declared capability")
207
+ end
208
+
209
+ normalized
210
+ end.uniq
211
+ end
212
+
213
+ def normalize_endpoint(value, context)
214
+ unless value.is_a?(Hash)
215
+ raise invalid("#{context} must be a mapping with kind and url")
216
+ end
217
+
218
+ kind = require_non_empty(value["kind"], "#{context}.kind must be a non-empty string").downcase
219
+ unless ENDPOINT_KINDS.include?(kind)
220
+ raise invalid("#{context}.kind must be one of: #{ENDPOINT_KINDS.join(", ")}")
221
+ end
222
+
223
+ url = require_non_empty(value["url"], "#{context}.url must be a non-empty string")
224
+ validate_endpoint_url(url, context)
225
+
226
+ {"kind" => kind, "url" => url}
227
+ end
228
+
229
+ # A malformed endpoint must fail configuration loading, never
230
+ # become a routing candidate with an empty public identity
231
+ # (review round 2, R1). Port bounds included: Ruby's URI parser
232
+ # accepts out-of-range ports (review round 4, F2).
233
+ def validate_endpoint_url(url, context)
234
+ uri = URI.parse(url)
235
+ usable = (uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)) &&
236
+ uri.host && !uri.host.empty? &&
237
+ uri.port.is_a?(Integer) && (1..65_535).cover?(uri.port)
238
+ raise invalid("#{context}.url must be an absolute http(s) URL with a host and a valid port") unless usable
239
+ rescue URI::Error, ArgumentError
240
+ raise invalid("#{context}.url must be an absolute http(s) URL with a host and a valid port")
241
+ end
242
+
243
+ def normalize_binding(value, context, allowed_kinds)
244
+ unless value.is_a?(Hash)
245
+ raise invalid("#{context} must be a mapping")
246
+ end
247
+
248
+ kind = require_non_empty(value["kind"], "#{context}.kind must be a non-empty string").downcase
249
+ unless allowed_kinds.include?(kind)
250
+ raise invalid("#{context}.kind must be one of: #{allowed_kinds.join(", ")}")
251
+ end
252
+
253
+ {
254
+ "kind" => kind,
255
+ "state" => optional_string(value["state"], "#{context}.state")&.downcase,
256
+ "instance_id" => optional_string(value["instance_id"], "#{context}.instance_id"),
257
+ "attested_instance_id" => optional_string(value["attested_instance_id"], "#{context}.attested_instance_id")
258
+ }
259
+ end
260
+
261
+ def require_non_empty(value, message)
262
+ raise invalid(message) unless value.is_a?(String) && !value.strip.empty?
263
+
264
+ value.strip
265
+ end
266
+
267
+ def optional_string(value, context)
268
+ return nil if value.nil?
269
+
270
+ require_non_empty(value, "#{context} must be a non-empty string")
271
+ end
272
+
273
+ def invalid(message)
274
+ Ace::Lab::InvalidConfigurationError.new("invalid lab configuration: #{message}")
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Agents < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ List lab agents in a project by stable ID
18
+
19
+ Agents are disclosed only to callers authorized for the project;
20
+ runtime pane/session bindings are never exposed.
21
+ DESC
22
+
23
+ example ["--project atlas --format json"]
24
+
25
+ option :project, type: :string, required: true, desc: "Project stable ID"
26
+ option :format, type: :string, desc: "Output format (json only)"
27
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
28
+
29
+ def initialize(topology_service: nil)
30
+ @topology_service = topology_service
31
+ end
32
+
33
+ def call(project:, **options)
34
+ ensure_json_format!(options)
35
+ reject_identity_flags!(options)
36
+
37
+ emit_result(topology_service.agents(project: project), quiet: options[:quiet])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Projects < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ List lab projects visible to the verified caller
18
+
19
+ Only projects covered by a configured authorization principal are
20
+ returned; a caller with no principal receives a classified
21
+ unauthorized error.
22
+ DESC
23
+
24
+ example ["--format json"]
25
+
26
+ option :format, type: :string, desc: "Output format (json only)"
27
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
28
+
29
+ def initialize(topology_service: nil)
30
+ @topology_service = topology_service
31
+ end
32
+
33
+ def call(**options)
34
+ ensure_json_format!(options)
35
+ reject_identity_flags!(options)
36
+
37
+ emit_result(topology_service.projects, quiet: options[:quiet])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Resolve < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Resolve one lab entry by exact stable ID
18
+
19
+ Labels are display values and never resolve; an unknown ID is a
20
+ classified missing error, a replaced process a classified stale
21
+ error.
22
+ DESC
23
+
24
+ example ["--id atlas-planner --format json"]
25
+
26
+ option :id, type: :string, required: true, desc: "Stable entry ID"
27
+ option :format, type: :string, desc: "Output format (json only)"
28
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
29
+
30
+ def initialize(topology_service: nil)
31
+ @topology_service = topology_service
32
+ end
33
+
34
+ def call(id:, **options)
35
+ ensure_json_format!(options)
36
+ reject_identity_flags!(options)
37
+
38
+ emit_result(topology_service.resolve(id: id), quiet: options[:quiet])
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Route < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ Select a configured capable service in a project
18
+
19
+ Routing never invokes a service or grants credentials; zero
20
+ candidates are missing, several without a configured default are
21
+ ambiguous.
22
+ DESC
23
+
24
+ example ["--project atlas --capability search --format json"]
25
+
26
+ option :project, type: :string, required: true, desc: "Project stable ID"
27
+ option :capability, type: :string, required: true, desc: "Capability to route"
28
+ option :format, type: :string, desc: "Output format (json only)"
29
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
30
+
31
+ def initialize(topology_service: nil)
32
+ @topology_service = topology_service
33
+ end
34
+
35
+ def call(project:, capability:, **options)
36
+ ensure_json_format!(options)
37
+ reject_identity_flags!(options)
38
+
39
+ emit_result(
40
+ topology_service.route(project: project, capability: capability),
41
+ quiet: options[:quiet]
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Lab
10
+ module CLI
11
+ module Commands
12
+ class Services < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc <<~DESC.strip
17
+ List lab services in a project by stable ID
18
+
19
+ Endpoints are sanitized to scheme/host identity; tokens, auth
20
+ files, userinfo, query parameters, and fragments never appear.
21
+ DESC
22
+
23
+ example ["--project atlas --format json"]
24
+
25
+ option :project, type: :string, required: true, desc: "Project stable ID"
26
+ option :format, type: :string, desc: "Output format (json only)"
27
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress output"
28
+
29
+ def initialize(topology_service: nil)
30
+ @topology_service = topology_service
31
+ end
32
+
33
+ def call(project:, **options)
34
+ ensure_json_format!(options)
35
+ reject_identity_flags!(options)
36
+
37
+ emit_result(topology_service.services(project: project), quiet: options[:quiet])
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require "ace/core"
6
+
7
+ module Ace
8
+ module Lab
9
+ module CLI
10
+ module Commands
11
+ # Shared collaborator construction for ace-lab commands. The topology
12
+ # service is injectable so tests can substitute fixtures; registered
13
+ # commands resolve the ADR-022 cascade. Output is deterministic JSON
14
+ # only; classified failures emit one error document and then raise
15
+ # for a non-zero exit (ADR-023).
16
+ module Runtime
17
+ def topology_service
18
+ @topology_service ||= Organisms::TopologyService.from_config
19
+ end
20
+
21
+ # The interface accepts only JSON; anything else is a usage error
22
+ def ensure_json_format!(options)
23
+ format = options[:format]
24
+ return if format.nil? || format == "json"
25
+
26
+ cli_error("--format #{format.inspect} is not supported; only --format json is available")
27
+ end
28
+
29
+ # Caller identity is derived from the verified local process owner.
30
+ # Role/principal flags would blur the authorization boundary and are
31
+ # rejected outright (spec 8wq.t.1w4).
32
+ def reject_identity_flags!(options)
33
+ spoofed = %i[role principal caller].select { |flag| options[flag] }
34
+ return if spoofed.empty?
35
+
36
+ cli_error("--#{spoofed.first.to_s.tr("_", "-")} is not supported; caller identity is derived " \
37
+ "from the verified local process owner")
38
+ end
39
+
40
+ # Emit the result envelope; classified errors still print exactly
41
+ # one deterministic JSON document before failing
42
+ def emit_result(result, quiet: false)
43
+ envelope = result.envelope
44
+ puts JSON.generate(envelope) unless quiet
45
+ return if result.ok?
46
+
47
+ raise Ace::Support::Cli::Error, "#{envelope["error"]["code"]}: #{envelope["error"]["message"]}"
48
+ end
49
+
50
+ def cli_error(message)
51
+ raise Ace::Support::Cli::Error, message
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../lab"
6
+ require_relative "cli/commands/projects"
7
+ require_relative "cli/commands/agents"
8
+ require_relative "cli/commands/services"
9
+ require_relative "cli/commands/resolve"
10
+ require_relative "cli/commands/route"
11
+
12
+ module Ace
13
+ module Lab
14
+ # ace-support-cli based CLI registry for ace-lab
15
+ module CLI
16
+ extend Ace::Support::Cli::RegistryDsl
17
+
18
+ PROGRAM_NAME = "ace-lab"
19
+
20
+ # Application commands with descriptions (for help output)
21
+ REGISTERED_COMMANDS = [
22
+ ["projects", "List lab projects visible to the verified caller"],
23
+ ["agents", "List lab agents in a project by stable ID"],
24
+ ["services", "List lab services in a project by stable ID"],
25
+ ["resolve", "Resolve one lab entry by exact stable ID"],
26
+ ["route", "Select a configured capable service in a project"]
27
+ ].freeze
28
+
29
+ HELP_EXAMPLES = [
30
+ "ace-lab projects --format json",
31
+ "ace-lab agents --project atlas --format json",
32
+ "ace-lab resolve --id atlas-planner --format json",
33
+ "ace-lab route --project atlas --capability search --format json"
34
+ ].freeze
35
+
36
+ # Start the CLI
37
+ #
38
+ # @param args [Array<String>] Command-line arguments
39
+ # @return [Integer] Exit code (0 for success, non-zero for failure)
40
+ def self.start(args)
41
+ Ace::Support::Cli::Runner.new(self).call(args: args)
42
+ end
43
+
44
+ # Register application commands
45
+ register "projects", CLI::Commands::Projects.new
46
+ register "agents", CLI::Commands::Agents.new
47
+ register "services", CLI::Commands::Services.new
48
+ register "resolve", CLI::Commands::Resolve.new
49
+ register "route", CLI::Commands::Route.new
50
+
51
+ # Register version command
52
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
53
+ gem_name: "ace-lab",
54
+ version: Ace::Lab::VERSION
55
+ )
56
+ register "version", version_cmd
57
+ register "--version", version_cmd
58
+
59
+ # Register help command
60
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
61
+ program_name: PROGRAM_NAME,
62
+ version: Ace::Lab::VERSION,
63
+ commands: REGISTERED_COMMANDS,
64
+ examples: HELP_EXAMPLES
65
+ )
66
+ register "help", help_cmd
67
+ register "--help", help_cmd
68
+ register "-h", help_cmd
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Models
6
+ # Immutable classified result of a topology query (spec 8wq.t.1w4).
7
+ # Either ok with public data, or a classified error with one of the
8
+ # ERROR_CODES. Serialized as the deterministic JSON envelope:
9
+ # {"status":"ok","data":{...}}
10
+ # {"status":"error","error":{"code":...,"message":...,...}}
11
+ class QueryResult
12
+ ERROR_CODES = %w[missing ambiguous stale unauthorized invalid_configuration].freeze
13
+
14
+ attr_reader :data, :error_code, :message, :context
15
+
16
+ def initialize(data: nil, error_code: nil, message: nil, context: {})
17
+ if error_code.nil? && message.nil?
18
+ @data = data
19
+ else
20
+ unless ERROR_CODES.include?(error_code)
21
+ raise ArgumentError, "unknown error code: #{error_code.inspect}"
22
+ end
23
+
24
+ @error_code = error_code
25
+ @message = message
26
+ @context = context.dup.freeze
27
+ end
28
+ freeze
29
+ end
30
+
31
+ def self.ok(data)
32
+ new(data: data)
33
+ end
34
+
35
+ def self.failure(code, message, **context)
36
+ new(error_code: code, message: message, context: context)
37
+ end
38
+
39
+ def ok?
40
+ (!error_code.nil?) ? false : true
41
+ end
42
+
43
+ def envelope
44
+ if ok?
45
+ {"status" => "ok", "data" => data}
46
+ else
47
+ error = {"code" => error_code, "message" => message}
48
+ error.merge!(context.select { |_k, v| !v.nil? })
49
+ {"status" => "error", "error" => error}
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end