diff --git a/OptimizelySDK.Tests/OdpTests/GraphQLManagerTest.cs b/OptimizelySDK.Tests/OdpTests/GraphQLManagerTest.cs new file mode 100644 index 00000000..f1024c51 --- /dev/null +++ b/OptimizelySDK.Tests/OdpTests/GraphQLManagerTest.cs @@ -0,0 +1,338 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +using Moq; +using Moq.Protected; +using NUnit.Framework; +using OptimizelySDK.AudienceConditions; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp; +using OptimizelySDK.Odp.Client; +using OptimizelySDK.Odp.Entity; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace OptimizelySDK.Tests.OdpTests +{ + [TestFixture] + public class GraphQLManagerTest + { + private const string VALID_ODP_PUBLIC_KEY = "not-real-odp-public-key"; + private const string ODP_GRAPHQL_URL = "https://example.com/endpoint"; + private const string FS_USER_ID = "fs_user_id"; + + private readonly List _segmentsToCheck = new List + { + "has_email", + "has_email_opted_in", + "push_on_sale", + }; + + private Mock _mockErrorHandler; + private Mock _mockLogger; + private Mock _mockOdpClient; + + [SetUp] + public void Setup() + { + _mockErrorHandler = new Mock(); + _mockLogger = new Mock(); + _mockLogger.Setup(i => i.Log(It.IsAny(), It.IsAny())); + + _mockOdpClient = new Mock(); + } + + [Test] + public void ShouldParseSuccessfulResponse() + { + const string RESPONSE_JSON = @" +{ + ""data"": { + ""customer"": { + ""audiences"": { + ""edges"": [ + { + ""node"": { + ""name"": ""has_email"", + ""state"": ""qualified"", + } + }, + { + ""node"": { + ""name"": ""has_email_opted_in"", + ""state"": ""not-qualified"" + } + }, + ] + }, + } + } +}"; + + var response = GraphQLManager.ParseSegmentsResponseJson(RESPONSE_JSON); + + Assert.IsNull(response.Errors); + Assert.IsNotNull(response.Data); + Assert.IsNotNull(response.Data.Customer); + Assert.IsNotNull(response.Data.Customer.Audiences); + Assert.IsNotNull(response.Data.Customer.Audiences.Edges); + Assert.IsTrue(response.Data.Customer.Audiences.Edges.Length == 2); + var node = response.Data.Customer.Audiences.Edges[0].Node; + Assert.AreEqual(node.Name, "has_email"); + Assert.AreEqual(node.State, BaseCondition.QUALIFIED); + node = response.Data.Customer.Audiences.Edges[1].Node; + Assert.AreEqual(node.Name, "has_email_opted_in"); + Assert.AreNotEqual(node.State, BaseCondition.QUALIFIED); + } + + [Test] + public void ShouldParseErrorResponse() + { + const string RESPONSE_JSON = @" +{ + ""errors"": [ + { + ""message"": ""Exception while fetching data (/customer) : Exception: could not resolve _fs_user_id = asdsdaddddd"", + ""locations"": [ + { + ""line"": 2, + ""column"": 3 + } + ], + ""path"": [ + ""customer"" + ], + ""extensions"": { + ""classification"": ""InvalidIdentifierException"" + } + } + ], + ""data"": { + ""customer"": null + } +}"; + + var response = GraphQLManager.ParseSegmentsResponseJson(RESPONSE_JSON); + + Assert.IsNull(response.Data.Customer); + Assert.IsNotNull(response.Errors); + Assert.AreEqual(response.Errors[0].Extensions.Classification, + "InvalidIdentifierException"); + } + + [Test] + public void ShouldFetchValidQualifiedSegments() + { + const string RESPONSE_DATA = "{\"data\":{\"customer\":{\"audiences\":" + + "{\"edges\":[{\"node\":{\"name\":\"has_email\"," + + "\"state\":\"qualified\"}},{\"node\":{\"name\":" + + "\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + _mockOdpClient.Setup( + c => c.QuerySegments(It.IsAny())). + Returns(RESPONSE_DATA); + var manager = new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, + _mockOdpClient.Object); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + FS_USER_ID, + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 2); + Assert.Contains("has_email", segments); + Assert.Contains("has_email_opted_in", segments); + _mockLogger.Verify(l => l.Log(LogLevel.WARN, It.IsAny()), Times.Never); + } + + [Test] + public void ShouldHandleEmptyQualifiedSegments() + { + const string RESPONSE_DATA = "{\"data\":{\"customer\":{\"audiences\":" + + "{\"edges\":[ ]}}}}"; + _mockOdpClient.Setup( + c => c.QuerySegments(It.IsAny())). + Returns(RESPONSE_DATA); + var manager = new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, + _mockOdpClient.Object); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + FS_USER_ID, + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify(l => l.Log(LogLevel.WARN, It.IsAny()), Times.Never); + } + + [Test] + public void ShouldHandleErrorWithInvalidIdentifier() + { + const string RESPONSE_DATA = "{\"errors\":[{\"message\":" + + "\"Exception while fetching data (/customer) : " + + "Exception: could not resolve _fs_user_id = invalid-user\"," + + "\"locations\":[{\"line\":1,\"column\":8}],\"path\":[\"customer\"]," + + "\"extensions\":{\"classification\":\"DataFetchingException\"}}]," + + "\"data\":{\"customer\":null}}"; + _mockOdpClient.Setup( + c => c.QuerySegments(It.IsAny())). + Returns(RESPONSE_DATA); + var manager = new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, + _mockOdpClient.Object); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + FS_USER_ID, + "invalid-user", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify(l => l.Log(LogLevel.WARN, It.IsAny()), + Times.Once); + } + + [Test] + public void ShouldHandleOtherException() + { + const string RESPONSE_DATA = "{\"errors\":[{\"message\":\"Validation error of type " + + "UnknownArgument: Unknown field argument not_real_userKey @ " + + "'customer'\",\"locations\":[{\"line\":1,\"column\":17}]," + + "\"extensions\":{\"classification\":\"ValidationError\"}}]}"; + + _mockOdpClient.Setup( + c => c.QuerySegments(It.IsAny())). + Returns(RESPONSE_DATA); + var manager = new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, + _mockOdpClient.Object); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + "not_real_userKey", + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify(l => l.Log(LogLevel.WARN, It.IsAny()), Times.Once); + } + + [Test] + public void ShouldHandleBadResponse() + { + const string RESPONSE_DATA = "{\"data\":{ }}"; + _mockOdpClient.Setup( + c => c.QuerySegments(It.IsAny())). + Returns(RESPONSE_DATA); + var manager = new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, + _mockOdpClient.Object); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + "not_real_userKey", + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify( + l => l.Log(LogLevel.ERROR, "Audience segments fetch failed (decode error)"), + Times.Once); + } + + [Test] + public void ShouldHandleUnrecognizedJsonResponse() + { + const string RESPONSE_DATA = + "{\"unExpectedObject\":{ \"withSome\": \"value\", \"thatIsNotParseable\": \"true\" }}"; + _mockOdpClient.Setup( + c => c.QuerySegments(It.IsAny())). + Returns(RESPONSE_DATA); + var manager = new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, + _mockOdpClient.Object); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + "not_real_userKey", + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify( + l => l.Log(LogLevel.ERROR, "Audience segments fetch failed (decode error)"), + Times.Once); + } + + [Test] + public void ShouldHandle400HttpCode() + { + var odpClient = new OdpClient(_mockErrorHandler.Object, _mockLogger.Object, + GetHttpClientThatReturnsStatus(HttpStatusCode.BadRequest)); + var manager = + new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, odpClient); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + FS_USER_ID, + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify(l => l.Log(LogLevel.ERROR, "Audience segments fetch failed (400)"), + Times.Once); + } + + [Test] + public void ShouldHandle500HttpCode() + { + var odpClient = new OdpClient(_mockErrorHandler.Object, _mockLogger.Object, + GetHttpClientThatReturnsStatus(HttpStatusCode.InternalServerError)); + var manager = + new GraphQLManager(_mockErrorHandler.Object, _mockLogger.Object, odpClient); + + var segments = manager.FetchSegments( + VALID_ODP_PUBLIC_KEY, + ODP_GRAPHQL_URL, + FS_USER_ID, + "tester-101", + _segmentsToCheck); + + Assert.IsTrue(segments.Length == 0); + _mockLogger.Verify(l => l.Log(LogLevel.ERROR, "Audience segments fetch failed (500)"), + Times.Once); + } + + private static HttpClient GetHttpClientThatReturnsStatus(HttpStatusCode statusCode) + { + var mockedHandler = new Mock(); + mockedHandler.Protected().Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()). + ReturnsAsync(() => new HttpResponseMessage(statusCode)); + return new HttpClient(mockedHandler.Object); + } + } +} diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index c563d781..d39fc44e 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -80,6 +80,7 @@ + @@ -159,4 +160,4 @@ --> - \ No newline at end of file + diff --git a/OptimizelySDK/Odp/Client/IOdpClient.cs b/OptimizelySDK/Odp/Client/IOdpClient.cs new file mode 100644 index 00000000..17c99d38 --- /dev/null +++ b/OptimizelySDK/Odp/Client/IOdpClient.cs @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +using OptimizelySDK.Odp.Entity; + +namespace OptimizelySDK.Odp.Client +{ + /// + /// An implementation for sending requests and handling responses to Optimizely Data Platform + /// + public interface IOdpClient + { + /// + /// Synchronous handler for querying the ODP GraphQL endpoint + /// + /// Parameters inputs to send to ODP + /// JSON response from ODP + string QuerySegments(QuerySegmentsParameters parameters); + } +} diff --git a/OptimizelySDK/Odp/Client/OdpClient.cs b/OptimizelySDK/Odp/Client/OdpClient.cs new file mode 100644 index 00000000..f8b4a5a7 --- /dev/null +++ b/OptimizelySDK/Odp/Client/OdpClient.cs @@ -0,0 +1,136 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp.Entity; +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace OptimizelySDK.Odp.Client +{ + /// + /// Http implementation for sending requests and handling responses to Optimizely Data Platform + /// + public class OdpClient : IOdpClient + { + /// + /// Error handler used to record errors + /// + private readonly IErrorHandler _errorHandler; + + /// + /// Logger used to record messages that occur within the ODP client + /// + private readonly ILogger _logger; + + /// + /// Http client used for handling requests and responses over HTTP + /// + private readonly HttpClient _client; + + /// + /// An implementation for sending requests and handling responses to Optimizely Data Platform (ODP) + /// + /// Handler to record exceptions + /// Collect and record events/errors for this ODP client + /// Client implementation to send/receive requests over HTTP + public OdpClient(IErrorHandler errorHandler = null, ILogger logger = null, + HttpClient client = null + ) + { + _errorHandler = errorHandler ?? new NoOpErrorHandler(); + _logger = logger ?? new NoOpLogger(); + _client = client ?? new HttpClient(); + } + + /// + /// Synchronous handler for querying the ODP GraphQL endpoint + /// + /// Parameters inputs to send to ODP + /// JSON response from ODP + public string QuerySegments(QuerySegmentsParameters parameters) + { + HttpResponseMessage response; + try + { + response = QuerySegmentsAsync(parameters).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _errorHandler.HandleError(ex); + _logger.Log(LogLevel.ERROR, "Audience segments fetch failed (network error)"); + return default; + } + + var responseStatusCode = (int)response.StatusCode; + if (responseStatusCode >= 400 && responseStatusCode < 600) + { + _logger.Log(LogLevel.ERROR, + $"Audience segments fetch failed ({responseStatusCode})"); + return default; + } + + return response.Content.ReadAsStringAsync().Result; + } + + /// + /// Asynchronous handler for querying the ODP GraphQL endpoint + /// + /// Parameters inputs to send to ODP + /// JSON response from ODP + private async Task QuerySegmentsAsync( + QuerySegmentsParameters parameters + ) + { + var request = BuildRequestMessage(parameters.ToGraphQLJson(), parameters); + + var response = await _client.SendAsync(request); + + return response; + } + + /// + /// Produces the request GraphQL query payload + /// + /// JSON GraphQL query + /// Configuration used to connect to ODP + /// Formed HTTP request message ready to be transmitted + private static HttpRequestMessage BuildRequestMessage(string jsonQuery, + QuerySegmentsParameters parameters + ) + { + const string API_HEADER_KEY = "x-api-key"; + const string CONTENT_TYPE = "application/json"; + var request = new HttpRequestMessage + { + RequestUri = new Uri(parameters.ApiHost), + Method = HttpMethod.Post, + Headers = + { + { + API_HEADER_KEY, parameters.ApiKey + }, + }, + Content = new StringContent(jsonQuery, Encoding.UTF8, CONTENT_TYPE), + }; + + return request; + } + } +} diff --git a/OptimizelySDK/Odp/Entity/Audience.cs b/OptimizelySDK/Odp/Entity/Audience.cs new file mode 100644 index 00000000..0ee7f86c --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Audience.cs @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Segment of a customer base + /// + public class Audience + { + /// + /// Collection of nodes within audience + /// + public Edge[] Edges { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/Customer.cs b/OptimizelySDK/Odp/Entity/Customer.cs new file mode 100644 index 00000000..a55006b9 --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Customer.cs @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Profile used to group/segment an addressable market + /// + public class Customer + { + /// + /// Segment of a customer base + /// + public Audience Audiences { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/Data.cs b/OptimizelySDK/Odp/Entity/Data.cs new file mode 100644 index 00000000..3e1e0176 --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Data.cs @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// GraphQL response data returned from a valid query + /// + public class Data + { + /// + /// Grouping of audiences within an overall customer base + /// + public Customer Customer { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/Edge.cs b/OptimizelySDK/Odp/Entity/Edge.cs new file mode 100644 index 00000000..288d2b1a --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Edge.cs @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Grouping of nodes within an audience + /// + public class Edge + { + /// + /// Atomic portions of a audience + /// + public Node Node { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/Error.cs b/OptimizelySDK/Odp/Entity/Error.cs new file mode 100644 index 00000000..d5b0ba66 --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Error.cs @@ -0,0 +1,49 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// GraphQL response from an errant query + /// + public class Error + { + /// + /// Human-readable message from the error + /// + public string Message { get; set; } + + /// + /// Points of failure producing the error + /// + public Location[] Locations { get; set; } + + /// + /// Files or urls producing the error + /// + public string[] Path { get; set; } + + /// + /// Additional technical error information + /// + public Extension Extensions { get; set; } + + public override string ToString() + { + return $"{Message}"; + } + } +} diff --git a/OptimizelySDK/Odp/Entity/Extension.cs b/OptimizelySDK/Odp/Entity/Extension.cs new file mode 100644 index 00000000..766f0a6c --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Extension.cs @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Extended error information + /// + public class Extension + { + /// + /// Named exception type from the error + /// + public string Classification { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/Location.cs b/OptimizelySDK/Odp/Entity/Location.cs new file mode 100644 index 00000000..2483ff6b --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Location.cs @@ -0,0 +1,34 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Specifies the precise place in code or data where the error occurred + /// + public class Location + { + /// + /// Code or data line number + /// + public int Line { get; set; } + + /// + /// Code or data column number + /// + public int Column { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/Node.cs b/OptimizelySDK/Odp/Entity/Node.cs new file mode 100644 index 00000000..745c0f3d --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Node.cs @@ -0,0 +1,34 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Atomic grouping an audience + /// + public class Node + { + /// + /// Descriptive label of a node + /// + public string Name { get; set; } + + /// + /// Status of the node + /// + public string State { get; set; } + } +} diff --git a/OptimizelySDK/Odp/Entity/QuerySegmentsParameters.cs b/OptimizelySDK/Odp/Entity/QuerySegmentsParameters.cs new file mode 100644 index 00000000..32e89c1c --- /dev/null +++ b/OptimizelySDK/Odp/Entity/QuerySegmentsParameters.cs @@ -0,0 +1,234 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +using Newtonsoft.Json; +using OptimizelySDK.Logger; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Handles parameters used in querying ODP segments + /// + public class QuerySegmentsParameters + { + public class Builder + { + /// + /// Builder API key + /// + private string ApiKey { get; set; } + + /// + /// Builder ODP endpoint + /// + private string ApiHost { get; set; } + + /// + /// Builder user type key + /// + private string UserKey { get; set; } + + /// + /// Builder user key value + /// + private string UserValue { get; set; } + + /// + /// Builder audience segments + /// + private List SegmentsToCheck { get; set; } + + /// + /// Builder logger to report problems during build + /// + private ILogger Logger { get; } + + public Builder(ILogger logger) + { + Logger = logger; + } + + /// + /// Sets the API key for accessing ODP + /// + /// Optimizely Data Platform API key + /// Current state of builder + public Builder WithApiKey(string apiKey) + { + ApiKey = apiKey; + return this; + } + + /// + /// Set the API endpoint for ODP + /// + /// Fully-qualified URL to ODP endpoint + /// Current state of builder + public Builder WithApiHost(string apiHost) + { + ApiHost = apiHost; + return this; + } + + /// + /// Sets the user key on which to query ODP + /// + /// 'vuid' or 'fs_user_id' + /// Current state of builder + public Builder WithUserKey(string userKey) + { + UserKey = userKey; + return this; + } + + /// + /// Set the user key's value + /// + /// Value for user key + /// Current state of builder + public Builder WithUserValue(string userValue) + { + UserValue = userValue; + return this; + } + + /// + /// Sets the segments to check + /// + /// List of audience segments to check + /// Current state of builder + public Builder WithSegmentsToCheck(List segmentsToCheck) + { + SegmentsToCheck = segmentsToCheck; + return this; + } + + /// + /// Validates and constructs the QuerySegmentsParameters object based on provided spec + /// + /// QuerySegmentsParameters object + public QuerySegmentsParameters Build() + { + const string INVALID_MISSING_BUILDER_INPUT_MESSAGE = + "QuerySegmentsParameters Builder was provided an invalid"; + + if (string.IsNullOrWhiteSpace(ApiKey)) + { + Logger.Log(LogLevel.ERROR, + $"{INVALID_MISSING_BUILDER_INPUT_MESSAGE} API Key"); + return default; + } + + if (string.IsNullOrWhiteSpace(ApiHost) || + !Uri.TryCreate(ApiHost, UriKind.Absolute, out Uri _)) + { + Logger.Log(LogLevel.ERROR, + $"{INVALID_MISSING_BUILDER_INPUT_MESSAGE} API Host"); + return default; + } + + if (string.IsNullOrWhiteSpace(UserKey) || !Enum.TryParse(UserKey, out UserKeyType _)) + { + Logger.Log(LogLevel.ERROR, + $"{INVALID_MISSING_BUILDER_INPUT_MESSAGE} User Key"); + return default; + } + + if (string.IsNullOrWhiteSpace(UserValue)) + { + Logger.Log(LogLevel.ERROR, + $"{INVALID_MISSING_BUILDER_INPUT_MESSAGE} User Value"); + return default; + } + + if (SegmentsToCheck.Any(string.IsNullOrWhiteSpace)) + { + Logger.Log(LogLevel.ERROR, + $"Segments To Check contained a null or empty segment"); + return default; + } + + return new QuerySegmentsParameters + { + ApiKey = ApiKey, + ApiHost = ApiHost, + UserKey = UserKey, + UserValue = UserValue, + SegmentsToCheck = SegmentsToCheck, + }; + } + + /// + /// Enumeration used during validation of User Key string + /// + private enum UserKeyType + { + vuid = 0, fs_user_id = 1 + } + } + + /// + /// Optimizely Data Platform API key + /// + public string ApiKey { get; private set; } + + /// + /// Fully-qualified URL to ODP endpoint + /// + public string ApiHost { get; private set; } + + /// + /// 'vuid' or 'fs_user_id' (client device id or fullstack id) + /// + private string UserKey { get; set; } + + /// + /// Value for the user key + /// + private string UserValue { get; set; } + + /// + /// Audience segments to check for inclusion in the experiment + /// + private List SegmentsToCheck { get; set; } + + private QuerySegmentsParameters() { } + + /// + /// Converts the current QuerySegmentsParameters into a GraphQL JSON string + /// + /// GraphQL JSON payload + public string ToGraphQLJson() + { + var userValueWithEscapedQuotes = $"\\\"{UserValue}\\\""; + var segmentsArrayJson = + JsonConvert.SerializeObject(SegmentsToCheck).Replace("\"", "\\\""); + + var json = new StringBuilder(); + json.Append("{\"query\" : \"query {customer"); + json.Append($"({UserKey} : {userValueWithEscapedQuotes}) "); + json.Append("{audiences"); + json.Append($"(subset: {segmentsArrayJson})"); + json.Append("{edges {node {name state}}}}}\"}"); + + return json.ToString(); + } + } +} diff --git a/OptimizelySDK/Odp/Entity/Response.cs b/OptimizelySDK/Odp/Entity/Response.cs new file mode 100644 index 00000000..303d0dc1 --- /dev/null +++ b/OptimizelySDK/Odp/Entity/Response.cs @@ -0,0 +1,45 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +namespace OptimizelySDK.Odp.Entity +{ + /// + /// Wrapper around valid data and error responses + /// + public class Response + { + /// + /// Valid query response information + /// + public Data Data { get; set; } + + /// + /// Set of errors produced while querying + /// + public Error[] Errors { get; set; } + + /// + /// Determines if an error exists + /// + public bool HasErrors + { + get + { + return Errors != null && Errors.Length > 0; + } + } + } +} diff --git a/OptimizelySDK/Odp/GraphQLManager.cs b/OptimizelySDK/Odp/GraphQLManager.cs new file mode 100644 index 00000000..a246c054 --- /dev/null +++ b/OptimizelySDK/Odp/GraphQLManager.cs @@ -0,0 +1,118 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +using Newtonsoft.Json; +using OptimizelySDK.AudienceConditions; +using OptimizelySDK.ErrorHandler; +using OptimizelySDK.Logger; +using OptimizelySDK.Odp.Client; +using OptimizelySDK.Odp.Entity; +using System.Collections.Generic; +using System.Linq; + +namespace OptimizelySDK.Odp +{ + /// + /// Manager for communicating with the Optimizely Data Platform GraphQL endpoint + /// + public class GraphQLManager : IGraphQLManager + { + private readonly ILogger _logger; + private readonly IOdpClient _odpClient; + + /// + /// Retrieves the audience segments from the Optimizely Data Platform (ODP) + /// + /// Handler to record exceptions + /// Collect and record events/errors for this GraphQL implementation + /// Client to use to send queries to ODP + public GraphQLManager(IErrorHandler errorHandler = null, ILogger logger = null, IOdpClient client = null) + { + _logger = logger ?? new NoOpLogger(); + _odpClient = client ?? new OdpClient(errorHandler ?? new NoOpErrorHandler(), _logger); + } + + /// + /// Retrieves the audience segments from ODP + /// + /// ODP public key + /// Fully-qualified URL of ODP + /// 'vuid' or 'fs_user_id key' + /// Associated value to query for the user key + /// Audience segments to check for experiment inclusion + /// Array of audience segments + public string[] FetchSegments(string apiKey, string apiHost, string userKey, + string userValue, List segmentsToCheck + ) + { + var emptySegments = new string[0]; + + var parameters = new QuerySegmentsParameters.Builder(_logger).WithApiKey(apiKey). + WithApiHost(apiHost).WithUserKey(userKey).WithUserValue(userValue). + WithSegmentsToCheck(segmentsToCheck).Build(); + + var segmentsResponseJson = _odpClient.QuerySegments(parameters); + if (CanBeJsonParsed(segmentsResponseJson)) + { + _logger.Log(LogLevel.WARN, $"Audience segments fetch failed"); + return emptySegments; + } + + var parsedSegments = ParseSegmentsResponseJson(segmentsResponseJson); + if (parsedSegments.HasErrors) + { + var errors = string.Join(";", parsedSegments.Errors.Select(e => e.ToString())); + + _logger.Log(LogLevel.WARN, $"Audience segments fetch failed ({errors})"); + + return emptySegments; + } + + if (parsedSegments?.Data?.Customer?.Audiences?.Edges is null) + { + _logger.Log(LogLevel.ERROR, "Audience segments fetch failed (decode error)"); + + return emptySegments; + } + + return parsedSegments.Data.Customer.Audiences.Edges. + Where(e => e.Node.State == BaseCondition.QUALIFIED). + Select(e => e.Node.Name).ToArray(); + } + + /// + /// Parses JSON response + /// + /// JSON response from ODP + /// Strongly-typed ODP Response object + public static Response ParseSegmentsResponseJson(string jsonResponse) + { + return CanBeJsonParsed(jsonResponse) ? + default : + JsonConvert.DeserializeObject(jsonResponse); + } + + /// + /// Ensure a string has content that can be parsed from JSON to an object + /// + /// Value containing possible JSON + /// True if content could be interpreted as JSON else False + private static bool CanBeJsonParsed(string jsonToValidate) + { + return string.IsNullOrWhiteSpace(jsonToValidate); + } + } +} diff --git a/OptimizelySDK/Odp/IGraphQLManager.cs b/OptimizelySDK/Odp/IGraphQLManager.cs new file mode 100644 index 00000000..e29c6a40 --- /dev/null +++ b/OptimizelySDK/Odp/IGraphQLManager.cs @@ -0,0 +1,39 @@ +/* + * Copyright 2022 Optimizely + * + * 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 + * + * https://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. + */ + +using System.Collections.Generic; + +namespace OptimizelySDK.Odp +{ + public interface IGraphQLManager + { + /// + /// Retrieves the audience segments from ODP + /// + /// ODP public key + /// Fully-qualified URL of ODP + /// 'vuid' or 'fs_user_id key' + /// Associated value to query for the user key + /// Audience segments to check for experiment inclusion + /// Array of audience segments + string[] FetchSegments(string apiKey, + string apiHost, + string userKey, + string userValue, + List segmentsToCheck + ); + } +} diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 4d08ff70..102ae901 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -88,6 +88,20 @@ + + + + + + + + + + + + + + @@ -177,7 +191,6 @@ - - \ No newline at end of file +