prefab-cloud-ruby 1.8.7 → 1.8.8
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 +4 -4
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/ruby.yml +1 -1
- data/.tool-versions +1 -1
- data/CHANGELOG.md +10 -0
- data/VERSION +1 -1
- data/lib/prefab/caching_http_connection.rb +95 -0
- data/lib/prefab/config_client.rb +7 -8
- data/lib/prefab/criteria_evaluator.rb +205 -2
- data/lib/prefab/fixed_size_hash.rb +14 -0
- data/lib/prefab/http_connection.rb +10 -6
- data/lib/prefab/semver.rb +132 -0
- data/lib/prefab-cloud-ruby.rb +3 -0
- data/lib/prefab_pb.rb +1 -1
- data/prefab-cloud-ruby.gemspec +26 -38
- data/test/support/common_helpers.rb +17 -12
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_criteria_evaluator.rb +436 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +1 -0
- metadata +10 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 543a1cb8d31c7ae3efbc8e63f020803a1813e5de5129cf6adb9093bbfad16a7d
|
4
|
+
data.tar.gz: 15b576851d86a6b1c36f0dc1982392eef70fc53ea01da63419e07128ca33ae0a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a9029c5d87c7c2e62c48697d7b40ddcf44d35e12fb211e7a4292f4212e110dcd42492c110b02d7f49df3985a23b4b76692c6367c2587572f9ea7fa177bd808be
|
7
|
+
data.tar.gz: 0f4fdeab62be128860fcd52b0c5de1486e6e3cb88e4494c155bdc7a6b4c65d4a34fb6adcab60c2ac418194b0398797d1441265716bce80d46d193b3cbae0ee14
|
@@ -0,0 +1,8 @@
|
|
1
|
+
## Description
|
2
|
+
*(Brief overview of what this PR changes and/or why the changes are needed)*
|
3
|
+
|
4
|
+
## Testing & Validation
|
5
|
+
*(Outline the steps needed to be taken to verify the changes.)*
|
6
|
+
|
7
|
+
## Rollout
|
8
|
+
*(Optional section: Provide rollout and rollback procedures, when outside the bounds or requiring additional work beyond standard deployment)*
|
data/.github/workflows/ruby.yml
CHANGED
data/.tool-versions
CHANGED
@@ -1 +1 @@
|
|
1
|
-
ruby 3.
|
1
|
+
ruby 3.2.7
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
# Changelog
|
2
2
|
|
3
|
+
|
4
|
+
## 1.8.8 - 2025-02-28
|
5
|
+
|
6
|
+
- Add conditional fetch support for configurations (#226)
|
7
|
+
- Operator support for string starts with, contains (#212)
|
8
|
+
- Operator support for regex, semver (protobuf update) (#215)
|
9
|
+
- Operator support for date comparison (before/after) (#221)
|
10
|
+
- Operator support for numeric comparisons (#220)
|
11
|
+
|
12
|
+
|
3
13
|
## 1.8.7 - 2024-10-25
|
4
14
|
|
5
15
|
- Add option symbolize_json_names (#211)
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
1.8.
|
1
|
+
1.8.8
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Prefab
|
4
|
+
class CachingHttpConnection
|
5
|
+
CACHE_SIZE = 2.freeze
|
6
|
+
CacheEntry = Struct.new(:data, :etag, :expires_at)
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def cache
|
10
|
+
@cache ||= FixedSizeHash.new(CACHE_SIZE)
|
11
|
+
end
|
12
|
+
|
13
|
+
def reset_cache!
|
14
|
+
@cache = FixedSizeHash.new(CACHE_SIZE)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(uri, api_key)
|
19
|
+
@connection = HttpConnection.new(uri, api_key)
|
20
|
+
end
|
21
|
+
|
22
|
+
def get(path)
|
23
|
+
now = Time.now.to_i
|
24
|
+
cache_key = "#{@connection.uri}#{path}"
|
25
|
+
cached = self.class.cache[cache_key]
|
26
|
+
|
27
|
+
# Check if we have a valid cached response
|
28
|
+
if cached&.data && cached.expires_at && now < cached.expires_at
|
29
|
+
return Faraday::Response.new(
|
30
|
+
status: 200,
|
31
|
+
body: cached.data,
|
32
|
+
response_headers: {
|
33
|
+
'ETag' => cached.etag,
|
34
|
+
'X-Cache' => 'HIT',
|
35
|
+
'X-Cache-Expires-At' => cached.expires_at.to_s
|
36
|
+
}
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Make request with conditional GET if we have an ETag
|
41
|
+
response = if cached&.etag
|
42
|
+
@connection.get(path, { 'If-None-Match' => cached.etag })
|
43
|
+
else
|
44
|
+
@connection.get(path)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Handle 304 Not Modified
|
48
|
+
if response.status == 304 && cached&.data
|
49
|
+
return Faraday::Response.new(
|
50
|
+
status: 200,
|
51
|
+
body: cached.data,
|
52
|
+
response_headers: {
|
53
|
+
'ETag' => cached.etag,
|
54
|
+
'X-Cache' => 'HIT',
|
55
|
+
'X-Cache-Expires-At' => cached.expires_at.to_s
|
56
|
+
}
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Parse caching headers
|
61
|
+
cache_control = response.headers['Cache-Control'].to_s
|
62
|
+
etag = response.headers['ETag']
|
63
|
+
|
64
|
+
# Always add X-Cache header
|
65
|
+
response.headers['X-Cache'] = 'MISS'
|
66
|
+
|
67
|
+
# Don't cache if no-store is present
|
68
|
+
return response if cache_control.include?('no-store')
|
69
|
+
|
70
|
+
# Calculate expiration
|
71
|
+
max_age = cache_control.match(/max-age=(\d+)/)&.captures&.first&.to_i
|
72
|
+
expires_at = max_age ? now + max_age : nil
|
73
|
+
|
74
|
+
# Cache the response if we have caching headers
|
75
|
+
if etag || expires_at
|
76
|
+
self.class.cache[cache_key] = CacheEntry.new(
|
77
|
+
response.body,
|
78
|
+
etag,
|
79
|
+
expires_at
|
80
|
+
)
|
81
|
+
end
|
82
|
+
|
83
|
+
response
|
84
|
+
end
|
85
|
+
|
86
|
+
# Delegate other methods to the underlying connection
|
87
|
+
def post(path, body)
|
88
|
+
@connection.post(path, body)
|
89
|
+
end
|
90
|
+
|
91
|
+
def uri
|
92
|
+
@connection.uri
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
data/lib/prefab/config_client.rb
CHANGED
@@ -118,7 +118,7 @@ module Prefab
|
|
118
118
|
end
|
119
119
|
|
120
120
|
def load_checkpoint
|
121
|
-
success = load_source_checkpoint
|
121
|
+
success = load_source_checkpoint(:start_at_id => @config_loader.highwater_mark)
|
122
122
|
return if success
|
123
123
|
|
124
124
|
success = load_cache
|
@@ -127,11 +127,10 @@ module Prefab
|
|
127
127
|
LOG.warn 'No success loading checkpoints'
|
128
128
|
end
|
129
129
|
|
130
|
-
def load_source_checkpoint
|
130
|
+
def load_source_checkpoint(start_at_id: 0)
|
131
131
|
@options.config_sources.each do |source|
|
132
|
-
conn = Prefab::
|
132
|
+
conn = Prefab::CachingHttpConnection.new("#{source}/api/v1/configs/#{start_at_id}", @base_client.api_key)
|
133
133
|
result = load_url(conn, :remote_api)
|
134
|
-
|
135
134
|
return true if result
|
136
135
|
end
|
137
136
|
|
@@ -146,18 +145,18 @@ module Prefab
|
|
146
145
|
cache_configs(configs)
|
147
146
|
true
|
148
147
|
else
|
149
|
-
LOG.info "Checkpoint #{source} failed to load. Response #{resp.status}"
|
148
|
+
LOG.info "Checkpoint #{source} [#{conn.uri}] failed to load. Response #{resp.status}"
|
150
149
|
false
|
151
150
|
end
|
152
151
|
rescue Faraday::ConnectionFailed => e
|
153
152
|
if !initialized?
|
154
|
-
LOG.warn "Connection Fail loading #{source} checkpoint."
|
153
|
+
LOG.warn "Connection Fail loading #{source} [#{conn.uri}] checkpoint."
|
155
154
|
else
|
156
|
-
LOG.debug "Connection Fail loading #{source} checkpoint."
|
155
|
+
LOG.debug "Connection Fail loading #{source} [#{conn.uri}] checkpoint."
|
157
156
|
end
|
158
157
|
false
|
159
158
|
rescue StandardError => e
|
160
|
-
LOG.warn "Unexpected #{source} problem loading checkpoint #{e} #{conn}"
|
159
|
+
LOG.warn "Unexpected #{source} [#{conn.uri}] problem loading checkpoint #{e} #{conn}"
|
161
160
|
LOG.debug e.backtrace
|
162
161
|
false
|
163
162
|
end
|
@@ -21,7 +21,7 @@ module Prefab
|
|
21
21
|
|
22
22
|
def evaluate(properties)
|
23
23
|
rtn = evaluate_for_env(@project_env_id, properties) ||
|
24
|
-
|
24
|
+
evaluate_for_env(0, properties)
|
25
25
|
LOG.trace {
|
26
26
|
"Eval Key #{@config.key} Result #{rtn&.reportable_value} with #{properties.to_h}"
|
27
27
|
} unless @config.config_type == :LOG_LEVEL
|
@@ -64,6 +64,22 @@ module Prefab
|
|
64
64
|
!PROP_ENDS_WITH_ONE_OF(criterion, properties)
|
65
65
|
end
|
66
66
|
|
67
|
+
def PROP_STARTS_WITH_ONE_OF(criterion, properties)
|
68
|
+
prop_starts_with_one_of?(criterion, value_from_properties(criterion, properties))
|
69
|
+
end
|
70
|
+
|
71
|
+
def PROP_DOES_NOT_START_WITH_ONE_OF(criterion, properties)
|
72
|
+
!PROP_STARTS_WITH_ONE_OF(criterion, properties)
|
73
|
+
end
|
74
|
+
|
75
|
+
def PROP_CONTAINS_ONE_OF(criterion, properties)
|
76
|
+
prop_contains_one_of?(criterion, value_from_properties(criterion, properties))
|
77
|
+
end
|
78
|
+
|
79
|
+
def PROP_DOES_NOT_CONTAIN_ONE_OF(criterion, properties)
|
80
|
+
!PROP_CONTAINS_ONE_OF(criterion, properties)
|
81
|
+
end
|
82
|
+
|
67
83
|
def HIERARCHICAL_MATCH(criterion, properties)
|
68
84
|
value = value_from_properties(criterion, properties)
|
69
85
|
value&.start_with?(criterion.value_to_match.string)
|
@@ -79,12 +95,129 @@ module Prefab
|
|
79
95
|
value && value >= criterion.value_to_match.int_range.start && value < criterion.value_to_match.int_range.end
|
80
96
|
end
|
81
97
|
|
98
|
+
def PROP_MATCHES(criterion, properties)
|
99
|
+
result = check_regex_match(criterion, properties)
|
100
|
+
if result.error
|
101
|
+
false
|
102
|
+
else
|
103
|
+
result.matched
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def PROP_DOES_NOT_MATCH(criterion, properties)
|
108
|
+
result = check_regex_match(criterion, properties)
|
109
|
+
if result.error
|
110
|
+
false
|
111
|
+
else
|
112
|
+
!result.matched
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def PROP_LESS_THAN(criterion, properties)
|
117
|
+
evaluate_number_comparison(criterion, properties, COMPARE_TO_OPERATORS[:less_than]).matched
|
118
|
+
end
|
119
|
+
|
120
|
+
def PROP_LESS_THAN_OR_EQUAL(criterion, properties)
|
121
|
+
evaluate_number_comparison(criterion, properties, COMPARE_TO_OPERATORS[:less_than_or_equal]).matched
|
122
|
+
end
|
123
|
+
|
124
|
+
def PROP_GREATER_THAN(criterion, properties)
|
125
|
+
evaluate_number_comparison(criterion, properties, COMPARE_TO_OPERATORS[:greater_than]).matched
|
126
|
+
end
|
127
|
+
|
128
|
+
def PROP_GREATER_THAN_OR_EQUAL(criterion, properties)
|
129
|
+
evaluate_number_comparison(criterion, properties,COMPARE_TO_OPERATORS[:greater_than_or_equal]) .matched
|
130
|
+
end
|
131
|
+
|
132
|
+
def PROP_BEFORE(criterion, properties)
|
133
|
+
evaluate_date_comparison(criterion, properties, COMPARE_TO_OPERATORS[:less_than]).matched
|
134
|
+
end
|
135
|
+
|
136
|
+
def PROP_AFTER(criterion, properties)
|
137
|
+
evaluate_date_comparison(criterion, properties, COMPARE_TO_OPERATORS[:greater_than]).matched
|
138
|
+
end
|
139
|
+
|
140
|
+
def PROP_SEMVER_LESS_THAN(criterion, properties)
|
141
|
+
evaluate_semver_comparison(criterion, properties, COMPARE_TO_OPERATORS[:less_than]).matched
|
142
|
+
end
|
143
|
+
|
144
|
+
def PROP_SEMVER_EQUAL(criterion, properties)
|
145
|
+
evaluate_semver_comparison(criterion, properties, COMPARE_TO_OPERATORS[:equal_to]).matched
|
146
|
+
end
|
147
|
+
|
148
|
+
def PROP_SEMVER_GREATER_THAN(criterion, properties)
|
149
|
+
evaluate_semver_comparison(criterion, properties, COMPARE_TO_OPERATORS[:greater_than]).matched
|
150
|
+
end
|
151
|
+
|
82
152
|
def value_from_properties(criterion, properties)
|
83
153
|
criterion.property_name == NAMESPACE_KEY ? @namespace : properties.get(criterion.property_name)
|
84
154
|
end
|
85
155
|
|
156
|
+
COMPARE_TO_OPERATORS = {
|
157
|
+
less_than_or_equal: -> cmp { cmp <= 0 },
|
158
|
+
less_than: -> cmp { cmp < 0 },
|
159
|
+
equal_to: -> cmp { cmp == 0 },
|
160
|
+
greater_than: -> cmp { cmp > 0 },
|
161
|
+
greater_than_or_equal: -> cmp { cmp >= 0 },
|
162
|
+
}
|
163
|
+
|
86
164
|
private
|
87
165
|
|
166
|
+
def evaluate_semver_comparison(criterion, properties, predicate)
|
167
|
+
context_version = value_from_properties(criterion, properties)&.then { |v| SemanticVersion.parse_quietly(v) }
|
168
|
+
config_version = criterion.value_to_match&.string&.then {|v| SemanticVersion.parse_quietly(criterion.value_to_match.string) }
|
169
|
+
|
170
|
+
unless context_version && config_version
|
171
|
+
return MatchResult.error
|
172
|
+
end
|
173
|
+
predicate.call(context_version <=> config_version) ? MatchResult.matched : MatchResult.not_matched
|
174
|
+
end
|
175
|
+
|
176
|
+
def evaluate_date_comparison(criterion, properties, predicate)
|
177
|
+
context_millis = as_millis(value_from_properties(criterion, properties))
|
178
|
+
config_millis = as_millis(Prefab::ConfigValueUnwrapper.deepest_value(criterion.value_to_match, @config,
|
179
|
+
properties, @resolver).unwrap)
|
180
|
+
|
181
|
+
unless config_millis && context_millis
|
182
|
+
return MatchResult.error
|
183
|
+
end
|
184
|
+
|
185
|
+
predicate.call(context_millis <=> config_millis) ? MatchResult.matched : MatchResult.not_matched
|
186
|
+
end
|
187
|
+
|
188
|
+
def evaluate_number_comparison(criterion, properties, predicate)
|
189
|
+
context_value = value_from_properties(criterion, properties)
|
190
|
+
value_to_match = extract_numeric_value(criterion.value_to_match)
|
191
|
+
|
192
|
+
return MatchResult.error if value_to_match.nil?
|
193
|
+
return MatchResult.error unless context_value.is_a?(Numeric)
|
194
|
+
|
195
|
+
# Compare the values and apply the predicate method
|
196
|
+
comparison_result = context_value <=> value_to_match
|
197
|
+
return MatchResult.error if comparison_result.nil?
|
198
|
+
|
199
|
+
predicate.call(comparison_result) ? MatchResult.matched : MatchResult.not_matched
|
200
|
+
end
|
201
|
+
|
202
|
+
def extract_numeric_value(config_value)
|
203
|
+
case config_value.type
|
204
|
+
when :int
|
205
|
+
config_value.int
|
206
|
+
when :double
|
207
|
+
config_value.double
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def as_millis(obj)
|
212
|
+
if obj.is_a?(Numeric)
|
213
|
+
return obj.to_int if obj.respond_to?(:to_int)
|
214
|
+
end
|
215
|
+
if obj.is_a?(String)
|
216
|
+
Time.iso8601(obj).utc.to_i * 1000 rescue nil
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
|
88
221
|
def evaluate_for_env(env_id, properties)
|
89
222
|
@config.rows.each_with_index do |row, index|
|
90
223
|
next unless row.project_env_id == env_id
|
@@ -115,7 +248,7 @@ module Prefab
|
|
115
248
|
end
|
116
249
|
|
117
250
|
def matches?(criterion, value, properties)
|
118
|
-
criterion_value_or_values = Prefab::ConfigValueUnwrapper.deepest_value(criterion.value_to_match, @config
|
251
|
+
criterion_value_or_values = Prefab::ConfigValueUnwrapper.deepest_value(criterion.value_to_match, @config,
|
119
252
|
properties, @resolver).unwrap
|
120
253
|
|
121
254
|
case criterion_value_or_values
|
@@ -135,6 +268,76 @@ module Prefab
|
|
135
268
|
value.end_with?(ending)
|
136
269
|
end
|
137
270
|
end
|
271
|
+
|
272
|
+
def prop_starts_with_one_of?(criterion, value)
|
273
|
+
return false unless value
|
274
|
+
|
275
|
+
criterion.value_to_match.string_list.values.any? do |beginning|
|
276
|
+
value.start_with?(beginning)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def prop_contains_one_of?(criterion, value)
|
281
|
+
return false unless value
|
282
|
+
|
283
|
+
criterion.value_to_match.string_list.values.any? do |substring|
|
284
|
+
value.include?(substring)
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
def check_regex_match(criterion, properties)
|
289
|
+
begin
|
290
|
+
regex_definition = Prefab::ConfigValueUnwrapper.deepest_value(criterion.value_to_match, @config.key,
|
291
|
+
properties, @resolver).unwrap
|
292
|
+
|
293
|
+
return MatchResult.error unless regex_definition.is_a?(String)
|
294
|
+
|
295
|
+
value = value_from_properties(criterion, properties)
|
296
|
+
|
297
|
+
regex = compile_regex_safely(ensure_anchored_regex(regex_definition))
|
298
|
+
return MatchResult.error unless regex
|
299
|
+
|
300
|
+
matches = regex.match?(value.to_s)
|
301
|
+
matches ? MatchResult.matched : MatchResult.not_matched
|
302
|
+
rescue RegexpError
|
303
|
+
MatchResult.error
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
def compile_regex_safely(pattern)
|
308
|
+
begin
|
309
|
+
Regexp.new(pattern)
|
310
|
+
rescue RegexpError
|
311
|
+
nil
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
def ensure_anchored_regex(pattern)
|
316
|
+
return pattern if pattern.start_with?("^") && pattern.end_with?("$")
|
317
|
+
|
318
|
+
"^#{pattern}$"
|
319
|
+
end
|
320
|
+
|
321
|
+
class MatchResult
|
322
|
+
attr_reader :matched, :error
|
323
|
+
|
324
|
+
def self.matched
|
325
|
+
new(matched: true)
|
326
|
+
end
|
327
|
+
|
328
|
+
def self.not_matched
|
329
|
+
new(matched: false)
|
330
|
+
end
|
331
|
+
|
332
|
+
def self.error
|
333
|
+
new(matched: false, error: true)
|
334
|
+
end
|
335
|
+
|
336
|
+
def initialize(matched:, error: false)
|
337
|
+
@matched = matched
|
338
|
+
@error = error
|
339
|
+
end
|
340
|
+
end
|
138
341
|
end
|
139
342
|
end
|
140
343
|
# rubocop:enable Naming/MethodName
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Prefab
|
3
|
+
class FixedSizeHash < Hash
|
4
|
+
def initialize(max_size)
|
5
|
+
@max_size = max_size
|
6
|
+
super()
|
7
|
+
end
|
8
|
+
|
9
|
+
def []=(key, value)
|
10
|
+
shift if size >= @max_size && !key?(key) # Only evict if adding a new key
|
11
|
+
super
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -9,13 +9,17 @@ module Prefab
|
|
9
9
|
'X-PrefabCloud-Client-Version' => "prefab-cloud-ruby-#{Prefab::VERSION}"
|
10
10
|
}.freeze
|
11
11
|
|
12
|
-
def initialize(
|
13
|
-
@
|
12
|
+
def initialize(uri, api_key)
|
13
|
+
@uri = uri
|
14
14
|
@api_key = api_key
|
15
15
|
end
|
16
16
|
|
17
|
-
def
|
18
|
-
|
17
|
+
def uri
|
18
|
+
@uri
|
19
|
+
end
|
20
|
+
|
21
|
+
def get(path, headers = {})
|
22
|
+
connection(PROTO_HEADERS.merge(headers)).get(path)
|
19
23
|
end
|
20
24
|
|
21
25
|
def post(path, body)
|
@@ -24,13 +28,13 @@ module Prefab
|
|
24
28
|
|
25
29
|
def connection(headers = {})
|
26
30
|
if Faraday::VERSION[0].to_i >= 2
|
27
|
-
Faraday.new(@
|
31
|
+
Faraday.new(@uri) do |conn|
|
28
32
|
conn.request :authorization, :basic, AUTH_USER, @api_key
|
29
33
|
|
30
34
|
conn.headers.merge!(headers)
|
31
35
|
end
|
32
36
|
else
|
33
|
-
Faraday.new(@
|
37
|
+
Faraday.new(@uri) do |conn|
|
34
38
|
conn.request :basic_auth, AUTH_USER, @api_key
|
35
39
|
|
36
40
|
conn.headers.merge!(headers)
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class SemanticVersion
|
4
|
+
include Comparable
|
5
|
+
|
6
|
+
SEMVER_PATTERN = /
|
7
|
+
^
|
8
|
+
(?<major>0|[1-9]\d*)
|
9
|
+
\.
|
10
|
+
(?<minor>0|[1-9]\d*)
|
11
|
+
\.
|
12
|
+
(?<patch>0|[1-9]\d*)
|
13
|
+
(?:-(?<prerelease>
|
14
|
+
(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)
|
15
|
+
(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*
|
16
|
+
))?
|
17
|
+
(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?
|
18
|
+
$
|
19
|
+
/x
|
20
|
+
|
21
|
+
attr_reader :major, :minor, :patch, :prerelease, :build_metadata
|
22
|
+
|
23
|
+
def self.parse(version_string)
|
24
|
+
raise ArgumentError, "version string cannot be nil" if version_string.nil?
|
25
|
+
raise ArgumentError, "version string cannot be empty" if version_string.empty?
|
26
|
+
|
27
|
+
match = SEMVER_PATTERN.match(version_string)
|
28
|
+
raise ArgumentError, "invalid semantic version format: #{version_string}" unless match
|
29
|
+
|
30
|
+
new(
|
31
|
+
major: match[:major].to_i,
|
32
|
+
minor: match[:minor].to_i,
|
33
|
+
patch: match[:patch].to_i,
|
34
|
+
prerelease: match[:prerelease],
|
35
|
+
build_metadata: match[:buildmetadata]
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.parse_quietly(version_string)
|
40
|
+
parse(version_string)
|
41
|
+
rescue ArgumentError
|
42
|
+
nil
|
43
|
+
end
|
44
|
+
|
45
|
+
def initialize(major:, minor:, patch:, prerelease: nil, build_metadata: nil)
|
46
|
+
@major = major
|
47
|
+
@minor = minor
|
48
|
+
@patch = patch
|
49
|
+
@prerelease = prerelease
|
50
|
+
@build_metadata = build_metadata
|
51
|
+
end
|
52
|
+
|
53
|
+
def <=>(other)
|
54
|
+
return nil unless other.is_a?(SemanticVersion)
|
55
|
+
|
56
|
+
# Compare major.minor.patch
|
57
|
+
return major <=> other.major if major != other.major
|
58
|
+
return minor <=> other.minor if minor != other.minor
|
59
|
+
return patch <=> other.patch if patch != other.patch
|
60
|
+
|
61
|
+
# Compare pre-release versions
|
62
|
+
compare_prerelease(prerelease, other.prerelease)
|
63
|
+
end
|
64
|
+
|
65
|
+
def ==(other)
|
66
|
+
return false unless other.is_a?(SemanticVersion)
|
67
|
+
|
68
|
+
major == other.major &&
|
69
|
+
minor == other.minor &&
|
70
|
+
patch == other.patch &&
|
71
|
+
prerelease == other.prerelease
|
72
|
+
# Build metadata is ignored in equality checks
|
73
|
+
end
|
74
|
+
|
75
|
+
def eql?(other)
|
76
|
+
self == other
|
77
|
+
end
|
78
|
+
|
79
|
+
def hash
|
80
|
+
[major, minor, patch, prerelease].hash
|
81
|
+
end
|
82
|
+
|
83
|
+
def to_s
|
84
|
+
result = "#{major}.#{minor}.#{patch}"
|
85
|
+
result += "-#{prerelease}" if prerelease
|
86
|
+
result += "+#{build_metadata}" if build_metadata
|
87
|
+
result
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def self.numeric?(str)
|
93
|
+
str.to_i.to_s == str
|
94
|
+
end
|
95
|
+
|
96
|
+
def compare_prerelease(pre1, pre2)
|
97
|
+
# If both are empty, they're equal
|
98
|
+
return 0 if pre1.nil? && pre2.nil?
|
99
|
+
|
100
|
+
# A version without prerelease has higher precedence
|
101
|
+
return 1 if pre1.nil?
|
102
|
+
return -1 if pre2.nil?
|
103
|
+
|
104
|
+
# Split into identifiers
|
105
|
+
ids1 = pre1.split('.')
|
106
|
+
ids2 = pre2.split('.')
|
107
|
+
|
108
|
+
# Compare each identifier until we find a difference
|
109
|
+
[ids1.length, ids2.length].min.times do |i|
|
110
|
+
cmp = compare_prerelease_identifiers(ids1[i], ids2[i])
|
111
|
+
return cmp if cmp != 0
|
112
|
+
end
|
113
|
+
|
114
|
+
# If all identifiers match up to the length of the shorter one,
|
115
|
+
# the longer one has higher precedence
|
116
|
+
ids1.length <=> ids2.length
|
117
|
+
end
|
118
|
+
|
119
|
+
def compare_prerelease_identifiers(id1, id2)
|
120
|
+
# If both are numeric, compare numerically
|
121
|
+
if self.class.numeric?(id1) && self.class.numeric?(id2)
|
122
|
+
return id1.to_i <=> id2.to_i
|
123
|
+
end
|
124
|
+
|
125
|
+
# If only one is numeric, numeric ones have lower precedence
|
126
|
+
return -1 if self.class.numeric?(id1)
|
127
|
+
return 1 if self.class.numeric?(id2)
|
128
|
+
|
129
|
+
# Neither is numeric, compare as strings
|
130
|
+
id1 <=> id2
|
131
|
+
end
|
132
|
+
end
|
data/lib/prefab-cloud-ruby.rb
CHANGED