diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index a1290210..7f6539df 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -51,4 +51,4 @@ runs: test_service_port: 9000 enable_persistence_tests: true token: ${{ inputs.token }} - version: v3.0.0-alpha.3 \ No newline at end of file + version: v3.0.0-alpha.4 diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index 1b6570f9..0add362f 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -3,6 +3,7 @@ require 'net/http' require 'launchdarkly-server-sdk' require './big_segment_store_fixture' +require './flag_change_listener' require './hook' require 'http' @@ -117,6 +118,8 @@ def initialize(log, config) config[:credential], LaunchDarkly::Config.new(opts), startWaitTimeMs / 1_000.0) + + @listeners = ListenerRegistry.new(@client.flag_tracker) end def initialized? @@ -225,7 +228,26 @@ def log @log end + def register_flag_change_listener(params) + @listeners.register_flag_change_listener(params[:listenerId], params[:callbackUri]) + end + + def register_flag_value_change_listener(params) + context = LaunchDarkly::LDContext.create(params[:context]) + @listeners.register_flag_value_change_listener( + params[:listenerId], + params[:flagKey], + context, + params[:callbackUri] + ) + end + + def unregister_listener(params) + @listeners.unregister(params[:listenerId]) + end + def close + @listeners.close_all @client.close @log.info("Test ended") end diff --git a/contract-tests/flag_change_listener.rb b/contract-tests/flag_change_listener.rb new file mode 100644 index 00000000..ca87812b --- /dev/null +++ b/contract-tests/flag_change_listener.rb @@ -0,0 +1,127 @@ +require 'http' +require 'json' + +# +# A listener that receives FlagChange events and POSTs notifications to a callback URI. +# Implements the #update method expected by the SDK's FlagTracker. +# +class FlagChangeCallbackListener + def initialize(listener_id, callback_uri) + @listener_id = listener_id + @callback_uri = callback_uri + end + + # @param flag_change [LaunchDarkly::Interfaces::FlagChange] + def update(flag_change) + payload = { + listenerId: @listener_id, + flagKey: flag_change.key, + } + HTTP.post(@callback_uri, json: payload) + rescue => e + # Log but don't re-raise; listener errors shouldn't crash the test service + $log.error("FlagChangeCallbackListener POST failed: #{e}") + end +end + +# +# A listener that receives FlagValueChange events and POSTs notifications to a callback URI. +# Implements the #update method expected by the SDK's FlagTracker (via FlagValueChangeAdapter). +# +class FlagValueChangeCallbackListener + def initialize(listener_id, callback_uri) + @listener_id = listener_id + @callback_uri = callback_uri + end + + # @param flag_value_change [LaunchDarkly::Interfaces::FlagValueChange] + def update(flag_value_change) + payload = { + listenerId: @listener_id, + flagKey: flag_value_change.key, + oldValue: flag_value_change.old_value, + newValue: flag_value_change.new_value, + } + HTTP.post(@callback_uri, json: payload) + rescue => e + $log.error("FlagValueChangeCallbackListener POST failed: #{e}") + end +end + +# +# Manages all active flag change listener registrations for a single SDK client entity. +# Thread-safe via a Mutex. +# +class ListenerRegistry + # @param tracker [LaunchDarkly::Interfaces::FlagTracker] + def initialize(tracker) + @tracker = tracker + @mu = Mutex.new + @listeners = {} # listenerId => listener object to pass to remove_listener + end + + # Registers a general flag change listener that fires on any flag configuration change. + # + # @param listener_id [String] + # @param callback_uri [String] + def register_flag_change_listener(listener_id, callback_uri) + listener = FlagChangeCallbackListener.new(listener_id, callback_uri) + @tracker.add_listener(listener) + store_listener(listener_id, listener) + end + + # Registers a flag value change listener that fires when the evaluated value of a + # specific flag changes for a given context. + # + # @param listener_id [String] + # @param flag_key [String] + # @param context [LaunchDarkly::LDContext] + # @param callback_uri [String] + def register_flag_value_change_listener(listener_id, flag_key, context, callback_uri) + inner_listener = FlagValueChangeCallbackListener.new(listener_id, callback_uri) + # add_flag_value_change_listener returns the adapter object that must be passed to + # remove_listener for unregistration. + adapter = @tracker.add_flag_value_change_listener(flag_key, context, inner_listener) + store_listener(listener_id, adapter) + end + + # Unregisters a previously registered listener by its ID. + # + # @param listener_id [String] + # @return [Boolean] true if the listener was found and removed + def unregister(listener_id) + listener = nil + @mu.synchronize do + listener = @listeners.delete(listener_id) + end + + return false if listener.nil? + + @tracker.remove_listener(listener) + true + end + + # Removes all registered listeners. Called when the SDK client entity shuts down. + def close_all + listeners_to_remove = nil + @mu.synchronize do + listeners_to_remove = @listeners.values + @listeners = {} + end + + listeners_to_remove.each do |listener| + @tracker.remove_listener(listener) + end + end + + # Stores a listener, cancelling any previously registered listener with the same ID. + private def store_listener(listener_id, listener) + old_listener = nil + @mu.synchronize do + old_listener = @listeners[listener_id] + @listeners[listener_id] = listener + end + + @tracker.remove_listener(old_listener) if old_listener + end +end diff --git a/contract-tests/service.rb b/contract-tests/service.rb index 1d6383c7..4d083696 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -51,6 +51,8 @@ 'persistent-data-store-consul', 'persistent-data-store-dynamodb', 'persistent-data-store-redis', + 'flag-change-listeners', + 'flag-value-change-listeners', ], }.to_json end @@ -128,6 +130,15 @@ when "contextComparison" response = {:equals => client.context_comparison(params[:contextComparison])} return [200, nil, response.to_json] + when "registerFlagChangeListener" + client.register_flag_change_listener(params[:registerFlagChangeListener]) + return 201 + when "registerFlagValueChangeListener" + client.register_flag_value_change_listener(params[:registerFlagValueChangeListener]) + return 201 + when "unregisterListener" + success = client.unregister_listener(params[:unregisterListener]) + return success ? 201 : [400, nil, {:error => "no listener with that id"}.to_json] end return [400, nil, {:error => "Unknown command requested"}.to_json]