prefab-cloud-ruby 1.8.7 → 1.8.8
Sign up to get free protection for your applications and to get access to all the features.
- 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