smplkit 1.0.5
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/CHANGELOG.md +5 -0
- data/LICENSE +21 -0
- data/README.md +105 -0
- data/lib/smplkit/client.rb +218 -0
- data/lib/smplkit/config/client.rb +238 -0
- data/lib/smplkit/config/helpers.rb +108 -0
- data/lib/smplkit/config/models.rb +192 -0
- data/lib/smplkit/config_resolution.rb +202 -0
- data/lib/smplkit/context.rb +68 -0
- data/lib/smplkit/debug.rb +50 -0
- data/lib/smplkit/errors.rb +114 -0
- data/lib/smplkit/flags/client.rb +480 -0
- data/lib/smplkit/flags/helpers.rb +76 -0
- data/lib/smplkit/flags/models.rb +258 -0
- data/lib/smplkit/flags/types.rb +233 -0
- data/lib/smplkit/generators/install_generator.rb +42 -0
- data/lib/smplkit/helpers.rb +15 -0
- data/lib/smplkit/log_level.rb +57 -0
- data/lib/smplkit/logging/adapters/base.rb +63 -0
- data/lib/smplkit/logging/adapters/semantic_logger_adapter.rb +88 -0
- data/lib/smplkit/logging/adapters/stdlib_logger_adapter.rb +143 -0
- data/lib/smplkit/logging/client.rb +142 -0
- data/lib/smplkit/logging/helpers.rb +69 -0
- data/lib/smplkit/logging/levels.rb +86 -0
- data/lib/smplkit/logging/models.rb +124 -0
- data/lib/smplkit/logging/normalize.rb +16 -0
- data/lib/smplkit/logging/sources.rb +44 -0
- data/lib/smplkit/management/buffer.rb +111 -0
- data/lib/smplkit/management/client.rb +623 -0
- data/lib/smplkit/management/models.rb +133 -0
- data/lib/smplkit/management/types.rb +65 -0
- data/lib/smplkit/metrics.rb +78 -0
- data/lib/smplkit/railtie.rb +48 -0
- data/lib/smplkit/version.rb +5 -0
- data/lib/smplkit/ws.rb +92 -0
- data/lib/smplkit.rb +43 -0
- data/sig/smplkit.rbs +141 -0
- metadata +139 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smplkit
|
|
4
|
+
module Config
|
|
5
|
+
# Type of a +ConfigItem+ value.
|
|
6
|
+
module ItemType
|
|
7
|
+
STRING = "STRING"
|
|
8
|
+
NUMBER = "NUMBER"
|
|
9
|
+
BOOLEAN = "BOOLEAN"
|
|
10
|
+
JSON = "JSON"
|
|
11
|
+
|
|
12
|
+
ALL = [STRING, NUMBER, BOOLEAN, JSON].freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# A single typed item in a +Config+.
|
|
16
|
+
class ConfigItem
|
|
17
|
+
attr_accessor :name, :value, :type, :description
|
|
18
|
+
|
|
19
|
+
def initialize(name:, value:, type:, description: nil)
|
|
20
|
+
@name = name
|
|
21
|
+
@value = value
|
|
22
|
+
@type = type
|
|
23
|
+
@description = description
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ "name" => @name, "value" => @value, "type" => @type, "description" => @description }.compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(other)
|
|
31
|
+
other.is_a?(ConfigItem) && other.name == @name && other.value == @value &&
|
|
32
|
+
other.type == @type && other.description == @description
|
|
33
|
+
end
|
|
34
|
+
alias eql? ==
|
|
35
|
+
|
|
36
|
+
def hash = [@name, @value, @type, @description].hash
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Per-environment value overrides for a +Config+.
|
|
40
|
+
#
|
|
41
|
+
# Read-only inspection container. Mutation is performed via +Config+'s
|
|
42
|
+
# setters with +environment:+ (e.g.
|
|
43
|
+
# +cfg.set_string("k", "v", environment: "production")+).
|
|
44
|
+
class ConfigEnvironment
|
|
45
|
+
def initialize(values: nil)
|
|
46
|
+
@values_raw = {}
|
|
47
|
+
return unless values
|
|
48
|
+
|
|
49
|
+
values.each do |k, v|
|
|
50
|
+
@values_raw[k] = v.is_a?(Hash) && v.key?("value") ? v : { "value" => v }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns overrides as a plain Hash +{ "key" => raw_value }+.
|
|
55
|
+
def values
|
|
56
|
+
@values_raw.transform_values { |v| v["value"] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the full typed overrides
|
|
60
|
+
# +{ "key" => { "value" => v, "type" => t, "description" => d } }+
|
|
61
|
+
# (read-only deep copy).
|
|
62
|
+
def values_raw
|
|
63
|
+
@values_raw.transform_values { |v| v.is_a?(Hash) ? v.dup : v }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def _replace_raw(values)
|
|
67
|
+
@values_raw = values
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A configuration resource — a typed bag of items with per-environment
|
|
72
|
+
# overrides.
|
|
73
|
+
#
|
|
74
|
+
# Provides management operations (save, set_string/set_number/...) and
|
|
75
|
+
# runtime evaluation via +get+ on the parent +ConfigClient+.
|
|
76
|
+
class Config
|
|
77
|
+
attr_accessor :id, :key, :name, :description, :parent_id, :created_at, :updated_at
|
|
78
|
+
|
|
79
|
+
def initialize(client = nil, key:, id: nil, name: nil, description: nil,
|
|
80
|
+
parent_id: nil, items: nil, environments: nil,
|
|
81
|
+
created_at: nil, updated_at: nil)
|
|
82
|
+
@client = client
|
|
83
|
+
@id = id
|
|
84
|
+
@key = key
|
|
85
|
+
@name = name
|
|
86
|
+
@description = description
|
|
87
|
+
@parent_id = parent_id
|
|
88
|
+
@items = items ? items.dup : []
|
|
89
|
+
@environments = environments ? environments.dup : {}
|
|
90
|
+
@created_at = created_at
|
|
91
|
+
@updated_at = updated_at
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def items
|
|
95
|
+
@items.dup
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def environments
|
|
99
|
+
@environments.dup
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def save
|
|
103
|
+
raise "Config was constructed without a client; cannot save" if @client.nil?
|
|
104
|
+
|
|
105
|
+
updated =
|
|
106
|
+
if @created_at.nil?
|
|
107
|
+
@client._create_config(self)
|
|
108
|
+
else
|
|
109
|
+
@client._update_config(self)
|
|
110
|
+
end
|
|
111
|
+
_apply(updated)
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
alias save! save
|
|
115
|
+
|
|
116
|
+
def delete
|
|
117
|
+
raise "Config was constructed without a client; cannot delete" if @client.nil?
|
|
118
|
+
|
|
119
|
+
@client.delete(@key)
|
|
120
|
+
end
|
|
121
|
+
alias delete! delete
|
|
122
|
+
|
|
123
|
+
def set_string(name, value, environment: nil, description: nil)
|
|
124
|
+
set_typed(name, value, ItemType::STRING, environment: environment, description: description)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def set_number(name, value, environment: nil, description: nil)
|
|
128
|
+
set_typed(name, value, ItemType::NUMBER, environment: environment, description: description)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def set_boolean(name, value, environment: nil, description: nil)
|
|
132
|
+
set_typed(name, value, ItemType::BOOLEAN, environment: environment, description: description)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def set_json(name, value, environment: nil, description: nil)
|
|
136
|
+
set_typed(name, value, ItemType::JSON, environment: environment, description: description)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def remove_item(name, environment: nil)
|
|
140
|
+
if environment
|
|
141
|
+
env = @environments[environment]
|
|
142
|
+
return unless env
|
|
143
|
+
|
|
144
|
+
raw = env.values_raw
|
|
145
|
+
raw.delete(name)
|
|
146
|
+
env._replace_raw(raw)
|
|
147
|
+
else
|
|
148
|
+
@items.reject! { |i| i.name == name }
|
|
149
|
+
end
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def _apply(other)
|
|
154
|
+
@id = other.id
|
|
155
|
+
@key = other.key
|
|
156
|
+
@name = other.name
|
|
157
|
+
@description = other.description
|
|
158
|
+
@parent_id = other.parent_id
|
|
159
|
+
@items = other.items
|
|
160
|
+
@environments = other.environments
|
|
161
|
+
@created_at = other.created_at
|
|
162
|
+
@updated_at = other.updated_at
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def set_typed(name, value, type, environment:, description: nil)
|
|
168
|
+
if environment.nil?
|
|
169
|
+
existing = @items.find { |i| i.name == name }
|
|
170
|
+
if existing
|
|
171
|
+
existing.value = value
|
|
172
|
+
existing.type = type
|
|
173
|
+
existing.description = description if description
|
|
174
|
+
else
|
|
175
|
+
@items << ConfigItem.new(name: name, value: value, type: type, description: description)
|
|
176
|
+
end
|
|
177
|
+
else
|
|
178
|
+
env = (@environments[environment] ||= ConfigEnvironment.new)
|
|
179
|
+
raw = env.values_raw
|
|
180
|
+
raw[name] = { "value" => value, "type" => type, "description" => description }.compact
|
|
181
|
+
env._replace_raw(raw)
|
|
182
|
+
end
|
|
183
|
+
self
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Top-level re-exports.
|
|
189
|
+
ConfigItem = Config::ConfigItem
|
|
190
|
+
ConfigEnvironment = Config::ConfigEnvironment
|
|
191
|
+
ItemType = Config::ItemType
|
|
192
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Smplkit
|
|
6
|
+
# SDK configuration resolution: defaults -> file -> env vars -> constructor args.
|
|
7
|
+
module ConfigResolution
|
|
8
|
+
CONFIG_KEYS = {
|
|
9
|
+
"api_key" => "SMPLKIT_API_KEY",
|
|
10
|
+
"base_domain" => "SMPLKIT_BASE_DOMAIN",
|
|
11
|
+
"scheme" => "SMPLKIT_SCHEME",
|
|
12
|
+
"environment" => "SMPLKIT_ENVIRONMENT",
|
|
13
|
+
"service" => "SMPLKIT_SERVICE",
|
|
14
|
+
"debug" => "SMPLKIT_DEBUG",
|
|
15
|
+
"telemetry" => "SMPLKIT_TELEMETRY"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
BOOL_TRUE = %w[true 1 yes].freeze
|
|
19
|
+
BOOL_FALSE = %w[false 0 no].freeze
|
|
20
|
+
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
"api_key" => nil,
|
|
23
|
+
"base_domain" => "smplkit.com",
|
|
24
|
+
"scheme" => "https",
|
|
25
|
+
"environment" => nil,
|
|
26
|
+
"service" => nil,
|
|
27
|
+
"debug" => false,
|
|
28
|
+
"telemetry" => true
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
ResolvedConfig = Data.define(
|
|
32
|
+
:api_key, :base_domain, :scheme, :environment, :service, :debug, :telemetry
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
ResolvedManagementConfig = Data.define(
|
|
36
|
+
:api_key, :base_domain, :scheme, :debug
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def parse_bool(value, key)
|
|
42
|
+
lower = value.to_s.strip.downcase
|
|
43
|
+
return true if BOOL_TRUE.include?(lower)
|
|
44
|
+
return false if BOOL_FALSE.include?(lower)
|
|
45
|
+
|
|
46
|
+
raise Error,
|
|
47
|
+
"Invalid boolean value for #{key}: #{value.inspect}. " \
|
|
48
|
+
"Expected one of: true, false, 1, 0, yes, no"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Build a service URL: {scheme}://{subdomain}.{base_domain}
|
|
52
|
+
def service_url(scheme, subdomain, base_domain)
|
|
53
|
+
"#{scheme}://#{subdomain}.#{base_domain}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Minimal INI parser. Returns { section_name => { key => value, ... } }.
|
|
57
|
+
def parse_ini(text)
|
|
58
|
+
sections = {}
|
|
59
|
+
current = nil
|
|
60
|
+
text.each_line do |line|
|
|
61
|
+
line = line.strip
|
|
62
|
+
next if line.empty? || line.start_with?("#", ";")
|
|
63
|
+
|
|
64
|
+
if line.start_with?("[") && line.end_with?("]")
|
|
65
|
+
name = line[1..-2].strip
|
|
66
|
+
current = (sections[name] ||= {})
|
|
67
|
+
elsif current
|
|
68
|
+
key, _, value = line.partition("=")
|
|
69
|
+
next if value.empty? && !line.include?("=")
|
|
70
|
+
|
|
71
|
+
current[key.strip] = value.strip
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
sections
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_config_file(profile, home_dir: nil)
|
|
78
|
+
home_dir ||= Dir.home
|
|
79
|
+
path = File.join(home_dir, ".smplkit")
|
|
80
|
+
return {} unless File.file?(path)
|
|
81
|
+
|
|
82
|
+
sections =
|
|
83
|
+
begin
|
|
84
|
+
parse_ini(File.read(path))
|
|
85
|
+
rescue StandardError
|
|
86
|
+
return {}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
values = {}
|
|
90
|
+
sections.fetch("common", {}).each { |k, v| values[k] = v unless v.empty? }
|
|
91
|
+
|
|
92
|
+
if sections.key?(profile)
|
|
93
|
+
sections[profile].each { |k, v| values[k] = v unless v.empty? }
|
|
94
|
+
else
|
|
95
|
+
non_common = sections.keys.reject { |s| s == "common" }
|
|
96
|
+
if !non_common.empty? && profile != "default"
|
|
97
|
+
raise Error,
|
|
98
|
+
"Profile [#{profile}] not found in ~/.smplkit. Available profiles: #{non_common.join(", ")}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
values
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_config(profile: nil, api_key: nil, base_domain: nil, scheme: nil,
|
|
106
|
+
environment: nil, service: nil, debug: nil, telemetry: nil,
|
|
107
|
+
home_dir: nil)
|
|
108
|
+
resolved = DEFAULTS.dup
|
|
109
|
+
|
|
110
|
+
active_profile = profile || ENV["SMPLKIT_PROFILE"] || "default"
|
|
111
|
+
|
|
112
|
+
file_values = read_config_file(active_profile, home_dir: home_dir)
|
|
113
|
+
CONFIG_KEYS.each_key do |key|
|
|
114
|
+
next unless file_values.key?(key)
|
|
115
|
+
|
|
116
|
+
val = file_values[key]
|
|
117
|
+
resolved[key] = %w[debug telemetry].include?(key) ? parse_bool(val, key) : val
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
CONFIG_KEYS.each do |key, env_var|
|
|
121
|
+
env_val = ENV.fetch(env_var, "")
|
|
122
|
+
next if env_val.empty?
|
|
123
|
+
|
|
124
|
+
resolved[key] = %w[debug telemetry].include?(key) ? parse_bool(env_val, env_var) : env_val
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ctor = {
|
|
128
|
+
"api_key" => api_key, "base_domain" => base_domain, "scheme" => scheme,
|
|
129
|
+
"environment" => environment, "service" => service,
|
|
130
|
+
"debug" => debug, "telemetry" => telemetry
|
|
131
|
+
}
|
|
132
|
+
ctor.each { |k, v| resolved[k] = v unless v.nil? }
|
|
133
|
+
|
|
134
|
+
missing_required(resolved, "environment", active_profile)
|
|
135
|
+
missing_required(resolved, "service", active_profile)
|
|
136
|
+
missing_required(resolved, "api_key", active_profile)
|
|
137
|
+
|
|
138
|
+
ResolvedConfig.new(
|
|
139
|
+
api_key: resolved["api_key"].to_s,
|
|
140
|
+
base_domain: resolved["base_domain"].to_s,
|
|
141
|
+
scheme: resolved["scheme"].to_s,
|
|
142
|
+
environment: resolved["environment"].to_s,
|
|
143
|
+
service: resolved["service"].to_s,
|
|
144
|
+
debug: resolved["debug"] ? true : false,
|
|
145
|
+
telemetry: resolved["telemetry"] ? true : false
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def resolve_management_config(profile: nil, api_key: nil, base_domain: nil,
|
|
150
|
+
scheme: nil, debug: nil, home_dir: nil)
|
|
151
|
+
resolved = {
|
|
152
|
+
"api_key" => nil,
|
|
153
|
+
"base_domain" => "smplkit.com",
|
|
154
|
+
"scheme" => "https",
|
|
155
|
+
"debug" => false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
active_profile = profile || ENV["SMPLKIT_PROFILE"] || "default"
|
|
159
|
+
|
|
160
|
+
file_values = read_config_file(active_profile, home_dir: home_dir)
|
|
161
|
+
%w[api_key base_domain scheme debug].each do |key|
|
|
162
|
+
next unless file_values.key?(key)
|
|
163
|
+
|
|
164
|
+
val = file_values[key]
|
|
165
|
+
resolved[key] = key == "debug" ? parse_bool(val, key) : val
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[
|
|
169
|
+
%w[api_key SMPLKIT_API_KEY], %w[base_domain SMPLKIT_BASE_DOMAIN],
|
|
170
|
+
%w[scheme SMPLKIT_SCHEME], %w[debug SMPLKIT_DEBUG]
|
|
171
|
+
].each do |key, env_var|
|
|
172
|
+
env_val = ENV.fetch(env_var, "")
|
|
173
|
+
next if env_val.empty?
|
|
174
|
+
|
|
175
|
+
resolved[key] = key == "debug" ? parse_bool(env_val, env_var) : env_val
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
ctor = { "api_key" => api_key, "base_domain" => base_domain, "scheme" => scheme, "debug" => debug }
|
|
179
|
+
ctor.each { |k, v| resolved[k] = v unless v.nil? }
|
|
180
|
+
|
|
181
|
+
missing_required(resolved, "api_key", active_profile)
|
|
182
|
+
|
|
183
|
+
ResolvedManagementConfig.new(
|
|
184
|
+
api_key: resolved["api_key"].to_s,
|
|
185
|
+
base_domain: resolved["base_domain"].to_s,
|
|
186
|
+
scheme: resolved["scheme"].to_s,
|
|
187
|
+
debug: resolved["debug"] ? true : false
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def missing_required(resolved, key, profile)
|
|
192
|
+
return if resolved[key]
|
|
193
|
+
|
|
194
|
+
env_var = CONFIG_KEYS[key]
|
|
195
|
+
raise Error,
|
|
196
|
+
"No #{key} provided. Set one of:\n " \
|
|
197
|
+
"1. Pass #{key} to the constructor\n " \
|
|
198
|
+
"2. Set the #{env_var} environment variable\n " \
|
|
199
|
+
"3. Add #{key} to the [#{profile}] section in ~/.smplkit"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smplkit
|
|
4
|
+
# Per-request context stash for context-sensitive evaluation (flags today,
|
|
5
|
+
# likely more later).
|
|
6
|
+
#
|
|
7
|
+
# Backed by per-Fiber storage (Ruby 3.2+) which falls through to per-Thread
|
|
8
|
+
# storage when not running under a Fiber scheduler. This gives proper
|
|
9
|
+
# per-request isolation under both threaded and fiber-based concurrency.
|
|
10
|
+
module RequestContext
|
|
11
|
+
KEY = :smplkit_request_context
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def get
|
|
16
|
+
Thread.current[KEY] || []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set(contexts)
|
|
20
|
+
previous = Thread.current[KEY]
|
|
21
|
+
Thread.current[KEY] = contexts.dup.freeze
|
|
22
|
+
previous
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reset(previous)
|
|
26
|
+
Thread.current[KEY] = previous
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returned by +Smplkit::Client#set_context+.
|
|
31
|
+
#
|
|
32
|
+
# Optional to use — bare +client.set_context([...])+ is fire-and-forget
|
|
33
|
+
# (typical middleware pattern). Holding the return value or using the block
|
|
34
|
+
# form auto-reverts to the prior context on exit, useful for scoped
|
|
35
|
+
# overrides like impersonation.
|
|
36
|
+
class ContextScope
|
|
37
|
+
def initialize(previous)
|
|
38
|
+
@previous = previous
|
|
39
|
+
@exited = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Block form support: +client.set_context([...]) { ... }+.
|
|
43
|
+
def call
|
|
44
|
+
yield self
|
|
45
|
+
ensure
|
|
46
|
+
exit
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Restores the prior context. Idempotent — subsequent calls no-op.
|
|
50
|
+
def exit
|
|
51
|
+
return if @exited
|
|
52
|
+
|
|
53
|
+
@exited = true
|
|
54
|
+
RequestContext.reset(@previous)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
def set_request_context(contexts)
|
|
61
|
+
previous = RequestContext.set(contexts)
|
|
62
|
+
ContextScope.new(previous)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def request_context
|
|
66
|
+
RequestContext.get
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smplkit
|
|
6
|
+
# Internal debug logging for the smplkit SDK.
|
|
7
|
+
#
|
|
8
|
+
# Controlled by the +SMPLKIT_DEBUG+ environment variable. When enabled
|
|
9
|
+
# (+SMPLKIT_DEBUG=1+, +true+, or +yes+, case-insensitive), the SDK emits
|
|
10
|
+
# timestamped diagnostic lines to stderr covering every meaningful internal
|
|
11
|
+
# operation.
|
|
12
|
+
#
|
|
13
|
+
# Debug output goes directly to +$stderr.write+ — never through Ruby's
|
|
14
|
+
# +Logger+ — to avoid interfering with the managed logging framework the SDK
|
|
15
|
+
# controls.
|
|
16
|
+
module Debug
|
|
17
|
+
TRUTHY = %w[1 true yes].freeze
|
|
18
|
+
|
|
19
|
+
@enabled = TRUTHY.include?(ENV.fetch("SMPLKIT_DEBUG", "").strip.downcase)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
attr_accessor :enabled
|
|
23
|
+
|
|
24
|
+
def enable!
|
|
25
|
+
@enabled = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enabled?
|
|
29
|
+
@enabled
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def emit(subsystem, message)
|
|
33
|
+
return unless @enabled
|
|
34
|
+
|
|
35
|
+
ts = Time.now.utc.iso8601(6)
|
|
36
|
+
$stderr.write("[smplkit:#{subsystem}] #{ts} #{message}\n")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
def debug(subsystem, message)
|
|
44
|
+
Debug.emit(subsystem, message)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enable_debug
|
|
48
|
+
Debug.enable!
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smplkit
|
|
6
|
+
# A single error object from the server's JSON:API +errors+ array.
|
|
7
|
+
class ApiErrorDetail
|
|
8
|
+
attr_reader :status, :title, :detail, :source
|
|
9
|
+
|
|
10
|
+
def initialize(status: nil, title: nil, detail: nil, source: nil)
|
|
11
|
+
@status = status
|
|
12
|
+
@title = title
|
|
13
|
+
@detail = detail
|
|
14
|
+
@source = source || {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
h = {}
|
|
19
|
+
h["status"] = @status unless @status.nil?
|
|
20
|
+
h["title"] = @title unless @title.nil?
|
|
21
|
+
h["detail"] = @detail unless @detail.nil?
|
|
22
|
+
h["source"] = @source unless @source.empty?
|
|
23
|
+
h
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_json(*)
|
|
27
|
+
JSON.generate(to_h, *)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Base exception for all smplkit SDK errors.
|
|
32
|
+
class Error < StandardError
|
|
33
|
+
attr_reader :errors, :status_code
|
|
34
|
+
|
|
35
|
+
def initialize(message = nil, errors: nil, status_code: nil)
|
|
36
|
+
@errors = errors || []
|
|
37
|
+
@status_code = status_code
|
|
38
|
+
message ||= self.class.derive_message(@errors)
|
|
39
|
+
super(message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
base = super
|
|
44
|
+
return base if @errors.empty?
|
|
45
|
+
|
|
46
|
+
if @errors.length == 1
|
|
47
|
+
"#{base}\nError: #{@errors[0].to_json}"
|
|
48
|
+
else
|
|
49
|
+
lines = [base, "Errors:"]
|
|
50
|
+
@errors.each_with_index { |err, i| lines << " [#{i}] #{err.to_json}" }
|
|
51
|
+
lines.join("\n")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.derive_message(errors)
|
|
56
|
+
return "An API error occurred" if errors.nil? || errors.empty?
|
|
57
|
+
|
|
58
|
+
first = errors[0]
|
|
59
|
+
msg = first.detail || first.title || first.status || "An API error occurred"
|
|
60
|
+
extra = errors.length - 1
|
|
61
|
+
msg += " (and 1 more error)" if extra == 1
|
|
62
|
+
msg += " (and #{extra} more errors)" if extra > 1
|
|
63
|
+
msg
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class ConnectionError < Error; end
|
|
68
|
+
class TimeoutError < Error; end
|
|
69
|
+
class NotFoundError < Error; end
|
|
70
|
+
class ConflictError < Error; end
|
|
71
|
+
class ValidationError < Error; end
|
|
72
|
+
|
|
73
|
+
module Errors
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
def parse_error_body(content)
|
|
77
|
+
body = JSON.parse(content)
|
|
78
|
+
raw_errors = body.is_a?(Hash) ? body["errors"] : nil
|
|
79
|
+
return [] unless raw_errors.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
raw_errors.filter_map do |item|
|
|
82
|
+
next unless item.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
ApiErrorDetail.new(
|
|
85
|
+
status: item["status"],
|
|
86
|
+
title: item["title"],
|
|
87
|
+
detail: item["detail"],
|
|
88
|
+
source: item["source"] || {}
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
rescue JSON::ParserError, EncodingError
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Parse a non-2xx response and raise the appropriate SDK exception.
|
|
96
|
+
# Raises nothing if status is 2xx.
|
|
97
|
+
def raise_for_status(status_code, content)
|
|
98
|
+
return if (200..299).cover?(status_code)
|
|
99
|
+
|
|
100
|
+
errors = parse_error_body(content)
|
|
101
|
+
message = errors.empty? ? "HTTP #{status_code}" : Error.derive_message(errors)
|
|
102
|
+
|
|
103
|
+
exc_cls =
|
|
104
|
+
case status_code
|
|
105
|
+
when 404 then NotFoundError
|
|
106
|
+
when 409 then ConflictError
|
|
107
|
+
when 400, 422 then ValidationError
|
|
108
|
+
else Error
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise exc_cls.new(message, errors: errors, status_code: status_code)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|