diff --git a/lib/optimizely/event_dispatcher.rb b/lib/optimizely/event_dispatcher.rb index aaa0b593..874a43db 100644 --- a/lib/optimizely/event_dispatcher.rb +++ b/lib/optimizely/event_dispatcher.rb @@ -17,6 +17,7 @@ # require_relative 'exceptions' require_relative 'helpers/http_utils' +require_relative 'helpers/constants' module Optimizely class NoOpEventDispatcher @@ -26,9 +27,6 @@ def dispatch_event(event); end end class EventDispatcher - # @api constants - REQUEST_TIMEOUT = 10 - def initialize(logger: nil, error_handler: nil, proxy_config: nil) @logger = logger || NoOpLogger.new @error_handler = error_handler || NoOpErrorHandler.new @@ -40,7 +38,7 @@ def initialize(logger: nil, error_handler: nil, proxy_config: nil) # @param event - Event object def dispatch_event(event) response = Helpers::HttpUtils.make_request( - event.url, event.http_verb, event.params.to_json, event.headers, REQUEST_TIMEOUT, @proxy_config + event.url, event.http_verb, event.params.to_json, event.headers, Helpers::Constants::EVENT_DISPATCH_CONFIG[:REQUEST_TIMEOUT], @proxy_config ) error_msg = "Event failed to dispatch with response code: #{response.code}" diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index eae4906f..84c57dea 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # -# Copyright 2016-2020, Optimizely and contributors +# Copyright 2016-2020, 2022, Optimizely and contributors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -382,6 +382,12 @@ module Constants 'EVALUATING_AUDIENCES_COMBINED' => "Evaluating audiences for rule '%s': %s." }.merge(AUDIENCE_EVALUATION_LOGS).freeze + ODP_LOGS = { + FETCH_SEGMENTS_FAILED: 'Audience segments fetch failed (%s).', + ODP_EVENT_FAILED: 'ODP event send failed (invalid url).', + ODP_NOT_ENABLED: 'ODP is not enabled.' + }.freeze + DECISION_NOTIFICATION_TYPES = { 'AB_TEST' => 'ab-test', 'FEATURE' => 'feature', @@ -406,6 +412,18 @@ module Constants 'REQUEST_TIMEOUT' => 10 }.freeze + EVENT_DISPATCH_CONFIG = { + REQUEST_TIMEOUT: 10 + }.freeze + + ODP_GRAPHQL_API_CONFIG = { + REQUEST_TIMEOUT: 10 + }.freeze + + ODP_REST_API_CONFIG = { + REQUEST_TIMEOUT: 10 + }.freeze + HTTP_HEADERS = { 'IF_MODIFIED_SINCE' => 'If-Modified-Since', 'LAST_MODIFIED' => 'Last-Modified' diff --git a/lib/optimizely/odp/zaius_graphql_api_manager.rb b/lib/optimizely/odp/zaius_graphql_api_manager.rb new file mode 100644 index 00000000..8123b69e --- /dev/null +++ b/lib/optimizely/odp/zaius_graphql_api_manager.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# +# Copyright 2022, Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +require 'json' + +module Optimizely + class ZaiusGraphQlApiManager + # Interface that handles fetching audience segments. + + def initialize(logger: nil, proxy_config: nil) + @logger = logger || NoOpLogger.new + @proxy_config = proxy_config + end + + # Fetch segments from the ODP GraphQL API. + # + # @param api_key - public api key + # @param api_host - domain url of the host + # @param user_key - vuid or fs_user_id (client device id or fullstack id) + # @param user_value - value of user_key + # @param segments_to_check - array of segments to check + + def fetch_segments(api_key, api_host, user_key, user_value, segments_to_check) + url = "#{api_host}/v3/graphql" + + headers = {'Content-Type' => 'application/json', 'x-api-key' => api_key.to_s} + + payload = { + 'query' => %'query {customer(#{user_key}: "#{user_value}")' \ + "{audiences(subset:#{segments_to_check || '[]'}) {edges {node {name state}}}}}" + }.to_json + + begin + response = Helpers::HttpUtils.make_request( + url, :post, payload, headers, Optimizely::Helpers::Constants::ODP_GRAPHQL_API_CONFIG[:REQUEST_TIMEOUT], @proxy_config + ) + rescue SocketError, Timeout::Error, Net::ProtocolError, Errno::ECONNRESET => e + @logger.log(Logger::DEBUG, "GraphQL download failed: #{e}") + log_failure('network error') + return [] + rescue Errno::EINVAL, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => e + log_failure(e) + return [] + end + + status = response.code.to_i + if status >= 400 + log_failure(status) + return [] + end + + begin + response = JSON.parse(response.body) + rescue JSON::ParserError + log_failure('JSON decode error') + return [] + end + + if response.include?('errors') + error_class = response['errors']&.first&.dig('extensions', 'classification') || 'decode error' + if error_class == 'InvalidIdentifierException' + log_failure('invalid identifier', Logger::WARN) + else + log_failure(error_class) + end + return [] + end + + audiences = response.dig('data', 'customer', 'audiences', 'edges') + unless audiences + log_failure('decode error') + return [] + end + + audiences.filter_map do |edge| + name = edge.dig('node', 'name') + state = edge.dig('node', 'state') + unless name && state + log_failure('decode error') + return [] + end + state == 'qualified' ? name : nil + end + end + + private + + def log_failure(message, level = Logger::ERROR) + @logger.log(level, format(Optimizely::Helpers::Constants::ODP_LOGS[:FETCH_SEGMENTS_FAILED], message)) + end + end +end diff --git a/spec/event_dispatcher_spec.rb b/spec/event_dispatcher_spec.rb index 499e8f09..193f584d 100644 --- a/spec/event_dispatcher_spec.rb +++ b/spec/event_dispatcher_spec.rb @@ -52,7 +52,7 @@ event.http_verb, event.params.to_json, event.headers, - Optimizely::EventDispatcher::REQUEST_TIMEOUT, + Optimizely::Helpers::Constants::EVENT_DISPATCH_CONFIG[:REQUEST_TIMEOUT], proxy_config ) diff --git a/spec/odp/zaius_graphql_api_manager_spec.rb b/spec/odp/zaius_graphql_api_manager_spec.rb new file mode 100644 index 00000000..91c3a14b --- /dev/null +++ b/spec/odp/zaius_graphql_api_manager_spec.rb @@ -0,0 +1,438 @@ +# frozen_string_literal: true + +# +# Copyright 2022, Optimizely and contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +require 'spec_helper' +require 'optimizely/odp/zaius_graphql_api_manager' + +describe Optimizely::ZaiusGraphQlApiManager do + let(:user_key) { 'vuid' } + let(:user_value) { 'test-user-value' } + let(:api_key) { 'test-api-key' } + let(:api_host) { 'https://test-host' } + let(:error_handler) { Optimizely::RaiseErrorHandler.new } + let(:spy_logger) { spy('logger') } + let(:zaius_manager) { Optimizely::ZaiusGraphQlApiManager.new(logger: spy_logger) } + let(:good_response_data) do + { + data: { + customer: { + audiences: { + edges: [ + { + node: { + name: 'a', + state: 'qualified', + description: 'qualifed sample 1' + } + }, + { + node: { + name: 'b', + state: 'qualified', + description: 'qualifed sample 2' + } + }, + { + node: { + name: 'c', + state: 'not_qualified', + description: 'not-qualified sample' + } + } + ] + } + } + } + } + end + let(:good_empty_response_data) do + { + data: { + customer: { + audiences: { + edges: [] + } + } + } + } + end + let(:invalid_identifier_response_data) do + { + errors: [ + { + message: "Exception while fetching data (/customer) :\ + java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd", + locations: [ + { + line: 2, + column: 3 + } + ], + path: [ + 'customer' + ], + extensions: { + classification: 'InvalidIdentifierException' + } + } + ], + data: { + customer: nil + } + } + end + let(:node_missing_response_data) do + { + data: { + customer: { + audiences: { + edges: [ + {} + ] + } + } + } + } + end + let(:mixed_missing_keys_response_data) do + { + data: { + customer: { + audiences: { + edges: [ + { + node: { + state: 'qualified' + } + }, + { + node: { + name: 'a' + } + }, + { + "other-name": { + name: 'a', + state: 'qualified' + } + } + ] + } + } + } + } + end + let(:other_exception_response_data) do + { + errors: [ + { + message: "Exception while fetching data (/customer) :\ + java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd", + extensions: { + classification: 'TestExceptionClass' + } + } + ], + data: { + customer: nil + } + } + end + let(:bad_response_data) { {data: {}} } + let(:name_invalid_response_data) do + '{ + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "a":::invalid-part-here:::, + "state": "qualified", + "description": "qualifed sample 1" + } + } + ] + } + } + } + }' + end + let(:invalid_edges_key_response_data) do + { + data: { + customer: { + audiences: { + invalid_test_key: [ + { + node: { + name: 'a', + state: 'qualified', + description: 'qualifed sample 1' + } + } + ] + } + } + } + } + end + let(:invalid_key_for_error_response_data) do + { + errors: [ + { + message: "Exception while fetching data (/customer) :\ + java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd", + locations: [ + { + line: 2, + column: 3 + } + ], + path: [ + 'customer' + ], + invalid_test_key: { + classification: 'InvalidIdentifierException' + } + } + ], + data: { + customer: nil + } + } + end + describe '.fetch_segments' do + it 'should get qualified segments when valid segments are given' do + stub_request(:post, "#{api_host}/v3/graphql") + .with( + headers: {'content-type': 'application/json', 'x-api-key': api_key}, + body: { + query: %'query {customer(#{user_key}: "#{user_value}")' \ + '{audiences(subset:["a", "b", "c"]) {edges {node {name state}}}}}' + } + ) + .to_return(status: 200, body: good_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b c]) + expect(segments).to match_array %w[a b] + end + + it 'should get empty array when empty array is given' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: good_empty_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, []) + expect(segments).to match_array [] + end + + it 'should log error and return empty array when response is missing node' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: node_missing_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (decode error).' + ) + end + + it 'should log error and return empty array when response keys are incorrect' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: mixed_missing_keys_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (decode error).' + ) + end + + it 'should log warning and return empty array with invalid identifier exception' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: invalid_identifier_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::WARN, + 'Audience segments fetch failed (invalid identifier).' + ) + end + + it 'should log error and return empty array with other exception' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: other_exception_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (TestExceptionClass).' + ) + end + + it 'should log error and return empty array with bad response data' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: bad_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (decode error).' + ) + end + + it 'should log error and return empty array with invalid name' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: name_invalid_response_data) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (JSON decode error).' + ) + end + + it 'should log error and return empty array with invalid key' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: invalid_edges_key_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (decode error).' + ) + end + + it 'should log error and return empty array with invalid key in error body' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 200, body: invalid_key_for_error_response_data.to_json) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (decode error).' + ) + end + + it 'should log error and return empty array with network error' do + stub_request(:post, "#{api_host}/v3/graphql") + .and_raise(SocketError) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (network error).' + ) + + expect(spy_logger).to have_received(:log).once.with( + Logger::DEBUG, + 'GraphQL download failed: Exception from WebMock' + ) + end + + it 'should log error and return empty array with http status 400' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 400) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (400).' + ) + end + + it 'should log error and return empty array with http status 500' do + stub_request(:post, "#{api_host}/v3/graphql") + .to_return(status: 500) + + segments = zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b]) + expect(segments).to match_array([]) + + expect(spy_logger).to have_received(:log).once.with( + Logger::ERROR, + 'Audience segments fetch failed (500).' + ) + end + + it 'should create correct subset filter' do + stub_request(:post, "#{api_host}/v3/graphql") + .with( + body: { + query: %'query {customer(#{user_key}: "#{user_value}")' \ + '{audiences(subset:[]) {edges {node {name state}}}}}' + } + ) + zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, nil) + + stub_request(:post, "#{api_host}/v3/graphql") + .with( + body: { + query: %'query {customer(#{user_key}: "#{user_value}")' \ + '{audiences(subset:[]) {edges {node {name state}}}}}' + } + ) + zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, []) + + stub_request(:post, "#{api_host}/v3/graphql") + .with( + body: { + query: %'query {customer(#{user_key}: "#{user_value}")' \ + '{audiences(subset:["a"]) {edges {node {name state}}}}}' + } + ) + zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a]) + + stub_request(:post, "#{api_host}/v3/graphql") + .with( + body: { + query: %'query {customer(#{user_key}: "#{user_value}")' \ + '{audiences(subset:["a", "b", "c"]) {edges {node {name state}}}}}' + } + ) + zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, %w[a b c]) + end + + it 'should pass the proxy config that is passed in' do + allow(Optimizely::Helpers::HttpUtils).to receive(:make_request).and_raise(SocketError) + stub_request(:post, "#{api_host}/v3/graphql") + + zaius_manager = Optimizely::ZaiusGraphQlApiManager.new(logger: spy_logger, proxy_config: :proxy_config) + zaius_manager.fetch_segments(api_key, api_host, user_key, user_value, []) + expect(Optimizely::Helpers::HttpUtils).to have_received(:make_request).with(anything, anything, anything, anything, anything, :proxy_config) + end + end +end