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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE +21 -0
  4. data/README.md +105 -0
  5. data/lib/smplkit/client.rb +218 -0
  6. data/lib/smplkit/config/client.rb +238 -0
  7. data/lib/smplkit/config/helpers.rb +108 -0
  8. data/lib/smplkit/config/models.rb +192 -0
  9. data/lib/smplkit/config_resolution.rb +202 -0
  10. data/lib/smplkit/context.rb +68 -0
  11. data/lib/smplkit/debug.rb +50 -0
  12. data/lib/smplkit/errors.rb +114 -0
  13. data/lib/smplkit/flags/client.rb +480 -0
  14. data/lib/smplkit/flags/helpers.rb +76 -0
  15. data/lib/smplkit/flags/models.rb +258 -0
  16. data/lib/smplkit/flags/types.rb +233 -0
  17. data/lib/smplkit/generators/install_generator.rb +42 -0
  18. data/lib/smplkit/helpers.rb +15 -0
  19. data/lib/smplkit/log_level.rb +57 -0
  20. data/lib/smplkit/logging/adapters/base.rb +63 -0
  21. data/lib/smplkit/logging/adapters/semantic_logger_adapter.rb +88 -0
  22. data/lib/smplkit/logging/adapters/stdlib_logger_adapter.rb +143 -0
  23. data/lib/smplkit/logging/client.rb +142 -0
  24. data/lib/smplkit/logging/helpers.rb +69 -0
  25. data/lib/smplkit/logging/levels.rb +86 -0
  26. data/lib/smplkit/logging/models.rb +124 -0
  27. data/lib/smplkit/logging/normalize.rb +16 -0
  28. data/lib/smplkit/logging/sources.rb +44 -0
  29. data/lib/smplkit/management/buffer.rb +111 -0
  30. data/lib/smplkit/management/client.rb +623 -0
  31. data/lib/smplkit/management/models.rb +133 -0
  32. data/lib/smplkit/management/types.rb +65 -0
  33. data/lib/smplkit/metrics.rb +78 -0
  34. data/lib/smplkit/railtie.rb +48 -0
  35. data/lib/smplkit/version.rb +5 -0
  36. data/lib/smplkit/ws.rb +92 -0
  37. data/lib/smplkit.rb +43 -0
  38. data/sig/smplkit.rbs +141 -0
  39. 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