launchdarkly-server-sdk 8.8.3-java

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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +13 -0
  3. data/README.md +61 -0
  4. data/lib/launchdarkly-server-sdk.rb +1 -0
  5. data/lib/ldclient-rb/cache_store.rb +45 -0
  6. data/lib/ldclient-rb/config.rb +658 -0
  7. data/lib/ldclient-rb/context.rb +565 -0
  8. data/lib/ldclient-rb/evaluation_detail.rb +387 -0
  9. data/lib/ldclient-rb/events.rb +642 -0
  10. data/lib/ldclient-rb/expiring_cache.rb +77 -0
  11. data/lib/ldclient-rb/flags_state.rb +88 -0
  12. data/lib/ldclient-rb/impl/big_segments.rb +117 -0
  13. data/lib/ldclient-rb/impl/broadcaster.rb +78 -0
  14. data/lib/ldclient-rb/impl/context.rb +96 -0
  15. data/lib/ldclient-rb/impl/context_filter.rb +166 -0
  16. data/lib/ldclient-rb/impl/data_source.rb +188 -0
  17. data/lib/ldclient-rb/impl/data_store.rb +109 -0
  18. data/lib/ldclient-rb/impl/dependency_tracker.rb +102 -0
  19. data/lib/ldclient-rb/impl/diagnostic_events.rb +129 -0
  20. data/lib/ldclient-rb/impl/evaluation_with_hook_result.rb +34 -0
  21. data/lib/ldclient-rb/impl/evaluator.rb +539 -0
  22. data/lib/ldclient-rb/impl/evaluator_bucketing.rb +86 -0
  23. data/lib/ldclient-rb/impl/evaluator_helpers.rb +50 -0
  24. data/lib/ldclient-rb/impl/evaluator_operators.rb +131 -0
  25. data/lib/ldclient-rb/impl/event_sender.rb +100 -0
  26. data/lib/ldclient-rb/impl/event_summarizer.rb +68 -0
  27. data/lib/ldclient-rb/impl/event_types.rb +136 -0
  28. data/lib/ldclient-rb/impl/flag_tracker.rb +58 -0
  29. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +170 -0
  30. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +300 -0
  31. data/lib/ldclient-rb/impl/integrations/file_data_source.rb +229 -0
  32. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +306 -0
  33. data/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb +40 -0
  34. data/lib/ldclient-rb/impl/migrations/migrator.rb +287 -0
  35. data/lib/ldclient-rb/impl/migrations/tracker.rb +136 -0
  36. data/lib/ldclient-rb/impl/model/clause.rb +45 -0
  37. data/lib/ldclient-rb/impl/model/feature_flag.rb +254 -0
  38. data/lib/ldclient-rb/impl/model/preprocessed_data.rb +64 -0
  39. data/lib/ldclient-rb/impl/model/segment.rb +132 -0
  40. data/lib/ldclient-rb/impl/model/serialization.rb +72 -0
  41. data/lib/ldclient-rb/impl/repeating_task.rb +46 -0
  42. data/lib/ldclient-rb/impl/sampler.rb +25 -0
  43. data/lib/ldclient-rb/impl/store_client_wrapper.rb +141 -0
  44. data/lib/ldclient-rb/impl/store_data_set_sorter.rb +55 -0
  45. data/lib/ldclient-rb/impl/unbounded_pool.rb +34 -0
  46. data/lib/ldclient-rb/impl/util.rb +95 -0
  47. data/lib/ldclient-rb/impl.rb +13 -0
  48. data/lib/ldclient-rb/in_memory_store.rb +100 -0
  49. data/lib/ldclient-rb/integrations/consul.rb +45 -0
  50. data/lib/ldclient-rb/integrations/dynamodb.rb +92 -0
  51. data/lib/ldclient-rb/integrations/file_data.rb +108 -0
  52. data/lib/ldclient-rb/integrations/redis.rb +98 -0
  53. data/lib/ldclient-rb/integrations/test_data/flag_builder.rb +663 -0
  54. data/lib/ldclient-rb/integrations/test_data.rb +213 -0
  55. data/lib/ldclient-rb/integrations/util/store_wrapper.rb +246 -0
  56. data/lib/ldclient-rb/integrations.rb +6 -0
  57. data/lib/ldclient-rb/interfaces.rb +974 -0
  58. data/lib/ldclient-rb/ldclient.rb +822 -0
  59. data/lib/ldclient-rb/memoized_value.rb +32 -0
  60. data/lib/ldclient-rb/migrations.rb +230 -0
  61. data/lib/ldclient-rb/non_blocking_thread_pool.rb +46 -0
  62. data/lib/ldclient-rb/polling.rb +102 -0
  63. data/lib/ldclient-rb/reference.rb +295 -0
  64. data/lib/ldclient-rb/requestor.rb +102 -0
  65. data/lib/ldclient-rb/simple_lru_cache.rb +25 -0
  66. data/lib/ldclient-rb/stream.rb +196 -0
  67. data/lib/ldclient-rb/util.rb +132 -0
  68. data/lib/ldclient-rb/version.rb +3 -0
  69. data/lib/ldclient-rb.rb +27 -0
  70. metadata +400 -0
@@ -0,0 +1,32 @@
1
+
2
+ module LaunchDarkly
3
+ # Simple implementation of a thread-safe memoized value whose generator function will never be
4
+ # run more than once, and whose value can be overridden by explicit assignment.
5
+ # Note that we no longer use this class and it will be removed in a future version.
6
+ # @private
7
+ class MemoizedValue
8
+ def initialize(&generator)
9
+ @generator = generator
10
+ @mutex = Mutex.new
11
+ @inited = false
12
+ @value = nil
13
+ end
14
+
15
+ def get
16
+ @mutex.synchronize do
17
+ unless @inited
18
+ @value = @generator.call
19
+ @inited = true
20
+ end
21
+ end
22
+ @value
23
+ end
24
+
25
+ def set(value)
26
+ @mutex.synchronize do
27
+ @value = value
28
+ @inited = true
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,230 @@
1
+ require 'ldclient-rb/impl/migrations/migrator'
2
+
3
+ module LaunchDarkly
4
+ #
5
+ # Namespace for feature-flag based technology migration support.
6
+ #
7
+ module Migrations
8
+ # Symbol representing the old origin, or the old technology source you are migrating away from.
9
+ ORIGIN_OLD = :old
10
+ # Symbol representing the new origin, or the new technology source you are migrating towards.
11
+ ORIGIN_NEW = :new
12
+
13
+ # Symbol defining a read-related operation
14
+ OP_READ = :read
15
+ # Symbol defining a write-related operation
16
+ OP_WRITE = :write
17
+
18
+ STAGE_OFF = :off
19
+ STAGE_DUALWRITE = :dualwrite
20
+ STAGE_SHADOW = :shadow
21
+ STAGE_LIVE = :live
22
+ STAGE_RAMPDOWN = :rampdown
23
+ STAGE_COMPLETE = :complete
24
+
25
+ VALID_OPERATIONS = [
26
+ OP_READ,
27
+ OP_WRITE,
28
+ ]
29
+
30
+ VALID_ORIGINS = [
31
+ ORIGIN_OLD,
32
+ ORIGIN_NEW,
33
+ ]
34
+
35
+ VALID_STAGES = [
36
+ STAGE_OFF,
37
+ STAGE_DUALWRITE,
38
+ STAGE_SHADOW,
39
+ STAGE_LIVE,
40
+ STAGE_RAMPDOWN,
41
+ STAGE_COMPLETE,
42
+ ]
43
+
44
+ #
45
+ # The OperationResult wraps the {LaunchDarkly::Result} class to tie an operation origin to a result.
46
+ #
47
+ class OperationResult
48
+ extend Forwardable
49
+ def_delegators :@result, :value, :error, :exception, :success?
50
+
51
+ #
52
+ # @param origin [Symbol]
53
+ # @param result [LaunchDarkly::Result]
54
+ #
55
+ def initialize(origin, result)
56
+ @origin = origin
57
+ @result = result
58
+ end
59
+
60
+ #
61
+ # @return [Symbol] The origin this result is associated with.
62
+ #
63
+ attr_reader :origin
64
+ end
65
+
66
+ #
67
+ # A write result contains the operation results against both the authoritative and non-authoritative origins.
68
+ #
69
+ # Authoritative writes are always executed first. In the event of a failure, the non-authoritative write will not
70
+ # be executed, resulting in a nil value in the final WriteResult.
71
+ #
72
+ class WriteResult
73
+ #
74
+ # @param authoritative [OperationResult]
75
+ # @param nonauthoritative [OperationResult, nil]
76
+ #
77
+ def initialize(authoritative, nonauthoritative = nil)
78
+ @authoritative = authoritative
79
+ @nonauthoritative = nonauthoritative
80
+ end
81
+
82
+ #
83
+ # Returns the operation result for the authoritative origin.
84
+ #
85
+ # @return [OperationResult]
86
+ #
87
+ attr_reader :authoritative
88
+
89
+ #
90
+ # Returns the operation result for the non-authoritative origin.
91
+ #
92
+ # This result might be nil as the non-authoritative write does not execute in every stage, and will not execute
93
+ # if the authoritative write failed.
94
+ #
95
+ # @return [OperationResult, nil]
96
+ #
97
+ attr_reader :nonauthoritative
98
+ end
99
+
100
+
101
+ #
102
+ # The migration builder is used to configure and construct an instance of a
103
+ # {LaunchDarkly::Interfaces::Migrations::Migrator}. This migrator can be used to perform LaunchDarkly assisted
104
+ # technology migrations through the use of migration-based feature flags.
105
+ #
106
+ class MigratorBuilder
107
+ EXECUTION_SERIAL = :serial
108
+ EXECUTION_RANDOM = :random
109
+ EXECUTION_PARALLEL = :parallel
110
+
111
+ VALID_EXECUTION_ORDERS = [EXECUTION_SERIAL, EXECUTION_RANDOM, EXECUTION_PARALLEL]
112
+ private_constant :VALID_EXECUTION_ORDERS
113
+
114
+ #
115
+ # @param client [LaunchDarkly::LDClient]
116
+ #
117
+ def initialize(client)
118
+ @client = client
119
+
120
+ # Default settings as required by the spec
121
+ @read_execution_order = EXECUTION_PARALLEL
122
+ @measure_latency = true
123
+ @measure_errors = true
124
+
125
+ @read_config = nil # @type [LaunchDarkly::Impl::Migrations::MigrationConfig, nil]
126
+ @write_config = nil # @type [LaunchDarkly::Impl::Migrations::MigrationConfig, nil]
127
+ end
128
+
129
+ #
130
+ # The read execution order influences the parallelism and execution order for read operations involving multiple
131
+ # origins.
132
+ #
133
+ # @param order [Symbol]
134
+ #
135
+ def read_execution_order(order)
136
+ return unless VALID_EXECUTION_ORDERS.include? order
137
+
138
+ @read_execution_order = order
139
+ end
140
+
141
+ #
142
+ # Enable or disable latency tracking for migration operations. This latency information can be sent upstream to
143
+ # LaunchDarkly to enhance migration visibility.
144
+ #
145
+ # @param enabled [Boolean]
146
+ #
147
+ def track_latency(enabled)
148
+ @measure_latency = !!enabled
149
+ end
150
+
151
+ #
152
+ # Enable or disable error tracking for migration operations. This error information can be sent upstream to
153
+ # LaunchDarkly to enhance migration visibility.
154
+ #
155
+ # @param enabled [Boolean]
156
+ #
157
+ def track_errors(enabled)
158
+ @measure_errors = !!enabled
159
+ end
160
+
161
+ #
162
+ # Read can be used to configure the migration-read behavior of the resulting
163
+ # {LaunchDarkly::Interfaces::Migrations::Migrator} instance.
164
+ #
165
+ # Users are required to provide two different read methods -- one to read from the old migration origin, and one
166
+ # to read from the new origin. Additionally, customers can opt-in to consistency tracking by providing a
167
+ # comparison function.
168
+ #
169
+ # Depending on the migration stage, one or both of these read methods may be called.
170
+ #
171
+ # The read methods should accept a single nullable parameter. This parameter is a payload passed through the
172
+ # {LaunchDarkly::Interfaces::Migrations::Migrator#read} method. This method should return a {LaunchDarkly::Result}
173
+ # instance.
174
+ #
175
+ # The consistency method should accept 2 parameters of any type. These parameters are the results of executing the
176
+ # read operation against the old and new origins. If both operations were successful, the consistency method will
177
+ # be invoked. This method should return true if the two parameters are equal, or false otherwise.
178
+ #
179
+ # @param old_read [#call]
180
+ # @param new_read [#call]
181
+ # @param comparison [#call, nil]
182
+ #
183
+ def read(old_read, new_read, comparison = nil)
184
+ return unless old_read.respond_to?(:call) && old_read.arity == 1
185
+ return unless new_read.respond_to?(:call) && new_read.arity == 1
186
+ return unless comparison.nil? || (comparison.respond_to?(:call) && comparison.arity == 2)
187
+
188
+ @read_config = LaunchDarkly::Impl::Migrations::MigrationConfig.new(old_read, new_read, comparison)
189
+ end
190
+
191
+ #
192
+ # Write can be used to configure the migration-write behavior of the resulting
193
+ # {LaunchDarkly::Interfaces::Migrations::Migrator} instance.
194
+ #
195
+ # Users are required to provide two different write methods -- one to write to the old migration origin, and one
196
+ # to write to the new origin.
197
+ #
198
+ # Depending on the migration stage, one or both of these write methods may be called.
199
+ #
200
+ # The write methods should accept a single nullable parameter. This parameter is a payload passed through the
201
+ # {LaunchDarkly::Interfaces::Migrations::Migrator#write} method. This method should return a {LaunchDarkly::Result}
202
+ # instance.
203
+ #
204
+ # @param old_write [#call]
205
+ # @param new_write [#call]
206
+ #
207
+ def write(old_write, new_write)
208
+ return unless old_write.respond_to?(:call) && old_write.arity == 1
209
+ return unless new_write.respond_to?(:call) && new_write.arity == 1
210
+
211
+ @write_config = LaunchDarkly::Impl::Migrations::MigrationConfig.new(old_write, new_write, nil)
212
+ end
213
+
214
+ #
215
+ # Build constructs a {LaunchDarkly::Interfaces::Migrations::Migrator} instance to support migration-based reads
216
+ # and writes. A string describing any failure conditions will be returned if the build fails.
217
+ #
218
+ # @return [LaunchDarkly::Interfaces::Migrations::Migrator, string]
219
+ #
220
+ def build
221
+ return "client not provided" if @client.nil?
222
+ return "read configuration not provided" if @read_config.nil?
223
+ return "write configuration not provided" if @write_config.nil?
224
+
225
+ LaunchDarkly::Impl::Migrations::Migrator.new(@client, @read_execution_order, @read_config, @write_config, @measure_latency, @measure_errors)
226
+ end
227
+ end
228
+
229
+ end
230
+ end
@@ -0,0 +1,46 @@
1
+ require "concurrent"
2
+ require "concurrent/atomics"
3
+ require "concurrent/executors"
4
+ require "thread"
5
+
6
+ module LaunchDarkly
7
+ # Simple wrapper for a FixedThreadPool that rejects new jobs if all the threads are busy, rather
8
+ # than blocking. Also provides a way to wait for all jobs to finish without shutting down.
9
+ # @private
10
+ class NonBlockingThreadPool
11
+ def initialize(capacity)
12
+ @capacity = capacity
13
+ @pool = Concurrent::FixedThreadPool.new(capacity)
14
+ @semaphore = Concurrent::Semaphore.new(capacity)
15
+ end
16
+
17
+ # Attempts to submit a job, but only if a worker is available. Unlike the regular post method,
18
+ # this returns a value: true if the job was submitted, false if all workers are busy.
19
+ def post
20
+ unless @semaphore.try_acquire(1)
21
+ return
22
+ end
23
+ @pool.post do
24
+ begin
25
+ yield
26
+ ensure
27
+ @semaphore.release(1)
28
+ end
29
+ end
30
+ end
31
+
32
+ # Waits until no jobs are executing, without shutting down the pool.
33
+ def wait_all
34
+ @semaphore.acquire(@capacity)
35
+ @semaphore.release(@capacity)
36
+ end
37
+
38
+ def shutdown
39
+ @pool.shutdown
40
+ end
41
+
42
+ def wait_for_termination
43
+ @pool.wait_for_termination
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,102 @@
1
+ require "ldclient-rb/impl/repeating_task"
2
+
3
+ require "concurrent/atomics"
4
+ require "json"
5
+ require "thread"
6
+
7
+ module LaunchDarkly
8
+ # @private
9
+ class PollingProcessor
10
+ def initialize(config, requestor)
11
+ @config = config
12
+ @requestor = requestor
13
+ @initialized = Concurrent::AtomicBoolean.new(false)
14
+ @started = Concurrent::AtomicBoolean.new(false)
15
+ @ready = Concurrent::Event.new
16
+ @task = Impl::RepeatingTask.new(@config.poll_interval, 0, -> { self.poll }, @config.logger)
17
+ end
18
+
19
+ def initialized?
20
+ @initialized.value
21
+ end
22
+
23
+ def start
24
+ return @ready unless @started.make_true
25
+ @config.logger.info { "[LDClient] Initializing polling connection" }
26
+ @task.start
27
+ @ready
28
+ end
29
+
30
+ def stop
31
+ stop_with_error_info
32
+ end
33
+
34
+ def poll
35
+ begin
36
+ all_data = @requestor.request_all_data
37
+ if all_data
38
+ update_sink_or_data_store.init(all_data)
39
+ if @initialized.make_true
40
+ @config.logger.info { "[LDClient] Polling connection initialized" }
41
+ @ready.set
42
+ end
43
+ end
44
+ @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil)
45
+ rescue JSON::ParserError => e
46
+ @config.logger.error { "[LDClient] JSON parsing failed for polling response." }
47
+ error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
48
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA,
49
+ 0,
50
+ e.to_s,
51
+ Time.now
52
+ )
53
+ @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info)
54
+ rescue UnexpectedResponseError => e
55
+ error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
56
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, e.status, nil, Time.now)
57
+ message = Util.http_error_message(e.status, "polling request", "will retry")
58
+ @config.logger.error { "[LDClient] #{message}" }
59
+
60
+ if Util.http_error_recoverable?(e.status)
61
+ @config.data_source_update_sink&.update_status(
62
+ LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
63
+ error_info
64
+ )
65
+ else
66
+ @ready.set # if client was waiting on us, make it stop waiting - has no effect if already set
67
+ stop_with_error_info error_info
68
+ end
69
+ rescue StandardError => e
70
+ Util.log_exception(@config.logger, "Exception while polling", e)
71
+ @config.data_source_update_sink&.update_status(
72
+ LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED,
73
+ LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN, 0, e.to_s, Time.now)
74
+ )
75
+ end
76
+ end
77
+
78
+ #
79
+ # The original implementation of this class relied on the feature store
80
+ # directly, which we are trying to move away from. Customers who might have
81
+ # instantiated this directly for some reason wouldn't know they have to set
82
+ # the config's sink manually, so we have to fall back to the store if the
83
+ # sink isn't present.
84
+ #
85
+ # The next major release should be able to simplify this structure and
86
+ # remove the need for fall back to the data store because the update sink
87
+ # should always be present.
88
+ #
89
+ private def update_sink_or_data_store
90
+ @config.data_source_update_sink || @config.feature_store
91
+ end
92
+
93
+ #
94
+ # @param [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] error_info
95
+ #
96
+ private def stop_with_error_info(error_info = nil)
97
+ @task.stop
98
+ @config.logger.info { "[LDClient] Polling connection stopped" }
99
+ @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error_info)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,295 @@
1
+ module LaunchDarkly
2
+ #
3
+ # Reference is an attribute name or path expression identifying a value
4
+ # within a Context.
5
+ #
6
+ # This type is mainly intended to be used internally by LaunchDarkly SDK and
7
+ # service code, where efficiency is a major concern so it's desirable to do
8
+ # any parsing or preprocessing just once. Applications are unlikely to need
9
+ # to use the Reference type directly.
10
+ #
11
+ # It can be used to retrieve a value with LDContext.get_value_for_reference()
12
+ # or to identify an attribute or nested value that should be considered
13
+ # private.
14
+ #
15
+ # Parsing and validation are done at the time that the Reference is
16
+ # constructed. If a Reference instance was created from an invalid string, it
17
+ # is considered invalid and its {Reference#error} attribute will return a
18
+ # non-nil error.
19
+ #
20
+ # ## Syntax
21
+ #
22
+ # The string representation of an attribute reference in LaunchDarkly JSON
23
+ # data uses the following syntax:
24
+ #
25
+ # If the first character is not a slash, the string is interpreted literally
26
+ # as an attribute name. An attribute name can contain any characters, but
27
+ # must not be empty.
28
+ #
29
+ # If the first character is a slash, the string is interpreted as a
30
+ # slash-delimited path where the first path component is an attribute name,
31
+ # and each subsequent path component is the name of a property in a JSON
32
+ # object. Any instances of the characters "/" or "~" in a path component are
33
+ # escaped as "~1" or "~0" respectively. This syntax deliberately resembles
34
+ # JSON Pointer, but no JSON Pointer behaviors other than those mentioned here
35
+ # are supported.
36
+ #
37
+ # ## Examples
38
+ #
39
+ # Suppose there is a context whose JSON implementation looks like this:
40
+ #
41
+ # {
42
+ # "kind": "user",
43
+ # "key": "value1",
44
+ # "address": {
45
+ # "street": {
46
+ # "line1": "value2",
47
+ # "line2": "value3"
48
+ # },
49
+ # "city": "value4"
50
+ # },
51
+ # "good/bad": "value5"
52
+ # }
53
+ #
54
+ # The attribute references "key" and "/key" would both point to "value1".
55
+ #
56
+ # The attribute reference "/address/street/line1" would point to "value2".
57
+ #
58
+ # The attribute references "good/bad" and "/good~1bad" would both point to
59
+ # "value5".
60
+ #
61
+ class Reference
62
+ ERR_EMPTY = 'empty reference'
63
+ private_constant :ERR_EMPTY
64
+
65
+ ERR_INVALID_ESCAPE_SEQUENCE = 'invalid escape sequence'
66
+ private_constant :ERR_INVALID_ESCAPE_SEQUENCE
67
+
68
+ ERR_DOUBLE_TRAILING_SLASH = 'double or trailing slash'
69
+ private_constant :ERR_DOUBLE_TRAILING_SLASH
70
+
71
+ #
72
+ # Returns nil for a valid Reference, or a non-nil error value for an
73
+ # invalid Reference.
74
+ #
75
+ # A Reference is invalid if the input string is empty, or starts with a
76
+ # slash but is not a valid slash-delimited path, or starts with a slash and
77
+ # contains an invalid escape sequence.
78
+ #
79
+ # Otherwise, the Reference is valid, but that does not guarantee that such
80
+ # an attribute exists in any given Context. For instance,
81
+ # Reference.create("name") is a valid Reference, but a specific Context
82
+ # might or might not have a name.
83
+ #
84
+ # See comments on the Reference type for more details of the attribute
85
+ # reference syntax.
86
+ #
87
+ # @return [String, nil]
88
+ #
89
+ attr_reader :error
90
+
91
+ #
92
+ # Returns the attribute reference as a string, in the same format provided
93
+ # to {#create}.
94
+ #
95
+ # If the Reference was created with {#create}, this value is identical to
96
+ # the original string. If it was created with {#create_literal}, the value
97
+ # may be different due to unescaping (for instance, an attribute whose name
98
+ # is "/a" would be represented as "~1a").
99
+ #
100
+ # @return [String, nil]
101
+ #
102
+ attr_reader :raw_path
103
+
104
+ def initialize(raw_path, components = [], error = nil)
105
+ @raw_path = raw_path
106
+ # @type [Array<Symbol>]
107
+ @components = components
108
+ @error = error
109
+ end
110
+ private_class_method :new
111
+
112
+ protected attr_reader :components
113
+
114
+ #
115
+ # Creates a Reference from a string. For the supported syntax and examples,
116
+ # see comments on the Reference type.
117
+ #
118
+ # This constructor always returns a Reference that preserves the original
119
+ # string, even if validation fails, so that accessing {#raw_path} (or
120
+ # serializing the Reference to JSON) will produce the original string. If
121
+ # validation fails, {#error} will return a non-nil error and any SDK method
122
+ # that takes this Reference as a parameter will consider it invalid.
123
+ #
124
+ # @param value [String, Symbol]
125
+ # @return [Reference]
126
+ #
127
+ def self.create(value)
128
+ unless value.is_a?(String) || value.is_a?(Symbol)
129
+ return new(value, [], ERR_EMPTY)
130
+ end
131
+
132
+ value = value.to_s if value.is_a?(Symbol)
133
+
134
+ return new(value, [], ERR_EMPTY) if value.empty? || value == "/"
135
+
136
+ unless value.start_with? "/"
137
+ return new(value, [value.to_sym])
138
+ end
139
+
140
+ if value.end_with? "/"
141
+ return new(value, [], ERR_DOUBLE_TRAILING_SLASH)
142
+ end
143
+
144
+ components = []
145
+ value[1..].split("/").each do |component|
146
+ if component.empty?
147
+ return new(value, [], ERR_DOUBLE_TRAILING_SLASH)
148
+ end
149
+
150
+ path, error = unescape_path(component)
151
+
152
+ if error
153
+ return new(value, [], error)
154
+ end
155
+
156
+ components << path.to_sym
157
+ end
158
+
159
+ new(value, components)
160
+ end
161
+
162
+ #
163
+ # create_literal is similar to {#create} except that it always
164
+ # interprets the string as a literal attribute name, never as a
165
+ # slash-delimited path expression. There is no escaping or unescaping, even
166
+ # if the name contains literal '/' or '~' characters. Since an attribute
167
+ # name can contain any characters, this method always returns a valid
168
+ # Reference unless the name is empty.
169
+ #
170
+ # For example: Reference.create_literal("name") is exactly equivalent to
171
+ # Reference.create("name"). Reference.create_literal("a/b") is exactly
172
+ # equivalent to Reference.create("a/b") (since the syntax used by {#create}
173
+ # treats the whole string as a literal as long as it does not start with a
174
+ # slash), or to Reference.create("/a~1b").
175
+ #
176
+ # @param value [String, Symbol]
177
+ # @return [Reference]
178
+ #
179
+ def self.create_literal(value)
180
+ unless value.is_a?(String) || value.is_a?(Symbol)
181
+ return new(value, [], ERR_EMPTY)
182
+ end
183
+
184
+ value = value.to_s if value.is_a?(Symbol)
185
+
186
+ return new(value, [], ERR_EMPTY) if value.empty?
187
+ return new(value, [value.to_sym]) if value[0] != '/'
188
+
189
+ escaped = "/" + value.gsub('~', '~0').gsub('/', '~1')
190
+ new(escaped, [value.to_sym])
191
+ end
192
+
193
+ #
194
+ # Returns the number of path components in the Reference.
195
+ #
196
+ # For a simple attribute reference such as "name" with no leading slash,
197
+ # this returns 1.
198
+ #
199
+ # For an attribute reference with a leading slash, it is the number of
200
+ # slash-delimited path components after the initial slash. For instance,
201
+ # NewRef("/a/b").Depth() returns 2.
202
+ #
203
+ # @return [Integer]
204
+ #
205
+ def depth
206
+ @components.size
207
+ end
208
+
209
+ #
210
+ # Retrieves a single path component from the attribute reference.
211
+ #
212
+ # For a simple attribute reference such as "name" with no leading slash, if
213
+ # index is zero, {#component} returns the attribute name as a symbol.
214
+ #
215
+ # For an attribute reference with a leading slash, if index is non-negative
216
+ # and less than {#depth}, Component returns the path component as a symbol.
217
+ #
218
+ # If index is out of range, it returns nil.
219
+ #
220
+ # Reference.create("a").component(0) # returns "a"
221
+ # Reference.create("/a/b").component(1) # returns "b"
222
+ #
223
+ # @param index [Integer]
224
+ # @return [Symbol, nil]
225
+ #
226
+ def component(index)
227
+ return nil if index < 0 || index >= depth
228
+
229
+ @components[index]
230
+ end
231
+
232
+ def ==(other)
233
+ self.error == other.error && self.components == other.components
234
+ end
235
+ alias eql? ==
236
+
237
+ def hash
238
+ ([error] + components).hash
239
+ end
240
+
241
+ #
242
+ # Convert the Reference to a JSON string.
243
+ #
244
+ # @param args [Array]
245
+ # @return [String]
246
+ #
247
+ def to_json(*args)
248
+ JSON.generate(@raw_path, *args)
249
+ end
250
+
251
+ #
252
+ # Performs unescaping of attribute reference path components:
253
+ #
254
+ # "~1" becomes "/"
255
+ # "~0" becomes "~"
256
+ # "~" followed by any character other than "0" or "1" is invalid
257
+ #
258
+ # This method returns an array of two values. The first element of the
259
+ # array is the path if unescaping was valid; otherwise, it will be nil. The
260
+ # second value is an error string, or nil if the unescaping was successful.
261
+ #
262
+ # @param path [String]
263
+ # @return [Array([String, nil], [String, nil])] Returns a fixed size array.
264
+ #
265
+ private_class_method def self.unescape_path(path)
266
+ # If there are no tildes then there's definitely nothing to do
267
+ return path, nil unless path.include? '~'
268
+
269
+ out = ""
270
+ i = 0
271
+ while i < path.size
272
+ if path[i] != "~"
273
+ out << path[i]
274
+ i += 1
275
+ next
276
+ end
277
+
278
+ return nil, ERR_INVALID_ESCAPE_SEQUENCE if i + 1 == path.size
279
+
280
+ case path[i + 1]
281
+ when '0'
282
+ out << "~"
283
+ when '1'
284
+ out << '/'
285
+ else
286
+ return nil, ERR_INVALID_ESCAPE_SEQUENCE
287
+ end
288
+
289
+ i += 2
290
+ end
291
+
292
+ [out, nil]
293
+ end
294
+ end
295
+ end