diff --git a/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts b/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts index fc0160d05..12d8fbc8f 100644 --- a/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts +++ b/packages/optimizely-sdk/lib/plugins/odp/graphql_manager.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import { ErrorHandler, LogHandler, LogLevel } from '../../modules/logging'; -import { Response } from './odp_types'; -import { IOdpClient, OdpClient } from './odp_client'; +import { LogHandler, LogLevel } from '../../modules/logging'; import { validate } from '../../utils/json_schema_validator'; import { OdpResponseSchema } from './odp_response_schema'; -import { QuerySegmentsParameters } from './query_segments_parameters'; -import { RequestHandlerFactory } from '../../utils/http_request_handler/request_handler_factory'; +import { ODP_USER_KEY } from '../../utils/enums'; +import { RequestHandler, Response as HttpResponse } from '../../utils/http_request_handler/http'; +import { Response as GraphQLResponse } from './odp_types'; /** * Expected value for a qualified/valid segment @@ -34,102 +33,145 @@ const EMPTY_SEGMENTS_COLLECTION: string[] = []; * Return value for scenarios with no valid JSON */ const EMPTY_JSON_RESPONSE = null; +/** + * Standard message for audience querying fetch errors + */ +const AUDIENCE_FETCH_FAILURE_MESSAGE = 'Audience segments fetch failed'; /** * Manager for communicating with the Optimizely Data Platform GraphQL endpoint */ export interface IGraphQLManager { - fetchSegments(apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[]): Promise; + fetchSegments(apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[]): Promise; } /** - * Concrete implementation for communicating with the Optimizely Data Platform GraphQL endpoint + * Concrete implementation for communicating with the ODP GraphQL endpoint */ -export class GraphqlManager implements IGraphQLManager { - private readonly _errorHandler: ErrorHandler; - private readonly _logger: LogHandler; - private readonly _odpClient: IOdpClient; +export class GraphQLManager implements IGraphQLManager { + private readonly logger: LogHandler; + private readonly requestHandler: RequestHandler; /** - * Retrieves the audience segments from the Optimizely Data Platform (ODP) - * @param errorHandler Handler to record exceptions + * Communicates with Optimizely Data Platform's GraphQL endpoint + * @param requestHandler Desired request handler for testing * @param logger Collect and record events/errors for this GraphQL implementation - * @param client Client to use to send queries to ODP */ - constructor(errorHandler: ErrorHandler, logger: LogHandler, client?: IOdpClient) { - this._errorHandler = errorHandler; - this._logger = logger; - - this._odpClient = client ?? new OdpClient(this._errorHandler, - this._logger, - RequestHandlerFactory.createHandler(this._logger)); + constructor(requestHandler: RequestHandler, logger: LogHandler) { + this.requestHandler = requestHandler; + this.logger = logger; } /** * Retrieves the audience segments from ODP * @param apiKey ODP public key - * @param apiHost Fully-qualified URL of ODP + * @param apiHost Host of ODP endpoint * @param userKey 'vuid' or 'fs_user_id key' * @param userValue Associated value to query for the user key * @param segmentsToCheck Audience segments to check for experiment inclusion */ - public async fetchSegments(apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[]): Promise { - const parameters = new QuerySegmentsParameters({ - apiKey, - apiHost, - userKey, - userValue, - segmentsToCheck, - }); - const segmentsResponse = await this._odpClient.querySegments(parameters); - if (!segmentsResponse) { - this._logger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)'); + public async fetchSegments(apiKey: string, apiHost: string, userKey: ODP_USER_KEY, userValue: string, segmentsToCheck: string[]): Promise { + if (!apiKey || !apiHost) { + this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (Parameters apiKey or apiHost invalid)`); + return null; + } + + if (segmentsToCheck?.length === 0) { return EMPTY_SEGMENTS_COLLECTION; } + const endpoint = `${apiHost}/v3/graphql`; + const query = this.toGraphQLJson(userKey, userValue, segmentsToCheck); + + const segmentsResponse = await this.querySegments(apiKey, endpoint, userKey, userValue, query); + if (!segmentsResponse) { + this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (network error)`); + return null; + } + const parsedSegments = this.parseSegmentsResponseJson(segmentsResponse); if (!parsedSegments) { - this._logger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)'); - return EMPTY_SEGMENTS_COLLECTION; + this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (decode error)`); + return null; } if (parsedSegments.errors?.length > 0) { const errors = parsedSegments.errors.map((e) => e.message).join('; '); - this._logger.log(LogLevel.WARNING, `Audience segments fetch failed (${errors})`); + this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (${errors})`); - return EMPTY_SEGMENTS_COLLECTION; + return null; } const edges = parsedSegments?.data?.customer?.audiences?.edges; if (!edges) { - this._logger.log(LogLevel.WARNING, 'Audience segments fetch failed (decode error)'); - return EMPTY_SEGMENTS_COLLECTION; + this.logger.log(LogLevel.ERROR, `${AUDIENCE_FETCH_FAILURE_MESSAGE} (decode error)`); + return null; } return edges.filter(edge => edge.node.state == QUALIFIED).map(edge => edge.node.name); } + /** + * Converts the query parameters to a GraphQL JSON payload + * @returns GraphQL JSON string + */ + private toGraphQLJson = (userKey: string, userValue: string, segmentsToCheck: string[]): string => ([ + '{"query" : "query {customer"', + `(${userKey} : "${userValue}") `, + '{audiences', + '(subset: [', + ...segmentsToCheck?.map((segment, index) => + `\\"${segment}\\"${index < segmentsToCheck.length - 1 ? ',' : ''}`, + ) || '', + '] {edges {node {name state}}}}}"}', + ].join('')); + + /** + * Handler for querying the ODP GraphQL endpoint + * @param apiKey ODP API key + * @param endpoint Fully-qualified GraphQL endpoint URL + * @param userKey 'vuid' or 'fs_user_id' + * @param userValue userKey's value + * @param query GraphQL formatted query string + * @returns JSON response string from ODP or null + */ + private async querySegments(apiKey: string, endpoint: string, userKey: string, userValue: string, query: string): Promise { + const method = 'POST'; + const url = endpoint; + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }; + + let response: HttpResponse; + try { + const request = this.requestHandler.makeRequest(url, headers, method, query); + response = await request.responsePromise; + } catch { + return null; + } + + return response.body; + } + /** * Parses JSON response * @param jsonResponse JSON response from ODP * @private * @returns Response Strongly-typed ODP Response object */ - private parseSegmentsResponseJson(jsonResponse: string): Response | null { + private parseSegmentsResponseJson(jsonResponse: string): GraphQLResponse | null { let jsonObject = {}; try { jsonObject = JSON.parse(jsonResponse); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - this._errorHandler.handleError(error); - this._logger.log(LogLevel.ERROR, 'Attempted to parse invalid segment response JSON.'); + } catch { return EMPTY_JSON_RESPONSE; } if (validate(jsonObject, OdpResponseSchema, false)) { - return jsonObject as Response; + return jsonObject as GraphQLResponse; } return EMPTY_JSON_RESPONSE; diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_client.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_client.ts deleted file mode 100644 index 416f312b5..000000000 --- a/packages/optimizely-sdk/lib/plugins/odp/odp_client.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 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. - */ - -import { ErrorHandler, LogHandler, LogLevel } from '../../modules/logging'; -import { QuerySegmentsParameters } from './query_segments_parameters'; -import { RequestHandler, Response } from '../../utils/http_request_handler/http'; -import { REQUEST_TIMEOUT_MS } from '../../utils/http_request_handler/config'; - -/** - * Standard failure message for fetch errors - */ -const FETCH_FAILURE_MESSAGE = 'Audience segments fetch failed'; -/** - * Return value for scenarios with no valid JSON - */ -const EMPTY_JSON_RESPONSE = null; - -/** - * Interface for sending requests and handling responses to Optimizely Data Platform - */ -export interface IOdpClient { - querySegments(parameters: QuerySegmentsParameters): Promise; -} - -/** - * Http implementation for sending requests and handling responses to Optimizely Data Platform - */ -export class OdpClient implements IOdpClient { - private readonly _errorHandler: ErrorHandler; - private readonly _logger: LogHandler; - private readonly _timeout: number; - private readonly _requestHandler: RequestHandler; - - /** - * An implementation for sending requests and handling responses to Optimizely Data Platform (ODP) - * @param errorHandler Handler to record exceptions - * @param logger Collect and record events/errors for this ODP client - * @param requestHandler Client implementation to send/receive requests over HTTP - * @param timeout Maximum milliseconds before requests are considered timed out - */ - constructor(errorHandler: ErrorHandler, logger: LogHandler, requestHandler: RequestHandler, timeout: number = REQUEST_TIMEOUT_MS) { - this._errorHandler = errorHandler; - this._logger = logger; - this._requestHandler = requestHandler; - this._timeout = timeout; - } - - /** - * Handler for querying the ODP GraphQL endpoint - * @param parameters - * @returns JSON response string from ODP - */ - public async querySegments(parameters: QuerySegmentsParameters): Promise { - if (!parameters?.apiHost || !parameters?.apiKey) { - this._logger.log(LogLevel.ERROR, 'No ApiHost or ApiKey set before querying segments'); - return EMPTY_JSON_RESPONSE; - } - - const method = 'POST'; - const url = parameters.apiHost; - const headers = { - 'Content-Type': 'application/json', - 'x-api-key': parameters.apiKey, - }; - const data = parameters.toGraphQLJson(); - - let response: Response; - try { - const request = this._requestHandler.makeRequest(url, headers, method, data); - response = await request.responsePromise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - this._errorHandler.handleError(error); - this._logger.log(LogLevel.ERROR, `${FETCH_FAILURE_MESSAGE} (${error.statusCode ?? 'network error'})`); - - return EMPTY_JSON_RESPONSE; - } - - return response.body; - } -} diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_event.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_event.ts new file mode 100644 index 000000000..4260cd30d --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_event.ts @@ -0,0 +1,51 @@ +/** + * 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. + */ + +export class OdpEvent { + /** + * Type of event (typically "fullstack") + */ + public type: string; + + /** + * Subcategory of the event type + */ + public action: string; + + /** + * Key-value map of user identifiers + */ + public identifiers: Map; + + /** + * Event data in a key-value map + */ + public data: Map; + + /** + * Event to be sent and stored in the Optimizely Data Platform + * @param type Type of event (typically "fullstack") + * @param action Subcategory of the event type + * @param identifiers Key-value map of user identifiers + * @param data Event data in a key-value map. + */ + constructor(type: string, action: string, identifiers?: Map, data?: Map) { + this.type = type; + this.action = action; + this.identifiers = identifiers ?? new Map(); + this.data = data ?? new Map(); + } +} diff --git a/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts b/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts index 98a16b4c3..9aad4ac35 100644 --- a/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts +++ b/packages/optimizely-sdk/lib/plugins/odp/odp_response_schema.ts @@ -17,7 +17,7 @@ import { JSONSchema4 } from 'json-schema'; /** - * ODP Response JSON Schema file used to validate the project json datafile + * JSON Schema used to validate the ODP GraphQL response */ export const OdpResponseSchema = { $schema: 'https://json-schema.org/draft/2019-09/schema', @@ -38,7 +38,7 @@ export const OdpResponseSchema = { customer: { title: 'The customer Schema', type: 'object', - required: [ ], + required: [], properties: { audiences: { title: 'The audiences Schema', @@ -107,15 +107,15 @@ export const OdpResponseSchema = { required: [ 'message', 'locations', - 'extensions' + 'extensions', ], properties: { message: { title: 'The message Schema', type: 'string', examples: [ - 'Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd' - ] + 'Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd', + ], }, locations: { title: 'The locations Schema', @@ -125,27 +125,27 @@ export const OdpResponseSchema = { type: 'object', required: [ 'line', - 'column' + 'column', ], properties: { line: { title: 'The line Schema', type: 'integer', examples: [ - 2 - ] + 2, + ], }, column: { title: 'The column Schema', type: 'integer', examples: [ - 3 - ] - } + 3, + ], + }, }, - examples: [] + examples: [], }, - examples: [] + examples: [], }, path: { title: 'The path Schema', @@ -154,32 +154,32 @@ export const OdpResponseSchema = { title: 'A Schema', type: 'string', examples: [ - 'customer' - ] + 'customer', + ], }, - examples: [] + examples: [], }, extensions: { title: 'The extensions Schema', type: 'object', required: [ - 'classification' + 'classification', ], properties: { classification: { title: 'The classification Schema', type: 'string', examples: [ - 'InvalidIdentifierException' - ] - } + 'InvalidIdentifierException', + ], + }, }, - examples: [] - } + examples: [], + }, }, - examples: [] + examples: [], }, - examples: [] + examples: [], }, }, examples: [], diff --git a/packages/optimizely-sdk/lib/plugins/odp/query_segments_parameters.ts b/packages/optimizely-sdk/lib/plugins/odp/query_segments_parameters.ts deleted file mode 100644 index aa12df677..000000000 --- a/packages/optimizely-sdk/lib/plugins/odp/query_segments_parameters.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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. - */ - -/** - * Handles parameters used in querying ODP segments - */ -export class QuerySegmentsParameters { - /** - * Optimizely Data Platform API key - */ - public apiKey?: string; - - /** - * Fully-qualified URL to ODP endpoint - */ - public apiHost: string | undefined; - - /** - * 'vuid' or 'fs_user_id' (client device id or fullstack id) - */ - public userKey: string | undefined; - - /** - * Value for the user key - */ - public userValue: string | undefined; - - /** - * Audience segments to check for inclusion in the experiment - */ - public segmentsToCheck: string[] | undefined; - - constructor(parameters: { apiKey: string, apiHost: string, userKey: string, userValue: string, segmentsToCheck: string[] }) { - Object.assign(this, parameters); - } - - /** - * Converts the QuerySegmentsParameters to a GraphQL JSON payload - * @returns GraphQL JSON string - */ - public toGraphQLJson(): string { - const segmentsArrayJson = JSON.stringify(this.segmentsToCheck); - - const json: string[] = []; - json.push('{"query" : "query {customer"'); - json.push(`(${this.userKey} : "${this.userValue}") `); - json.push('{audiences'); - json.push(`(subset: ${segmentsArrayJson})`); - json.push('{edges {node {name state}}}}}"}'); - - return json.join(''); - } -} diff --git a/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts b/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts new file mode 100644 index 000000000..de872f3cd --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/odp/rest_api_manager.ts @@ -0,0 +1,100 @@ +/** + * 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. + */ + +import { LogHandler, LogLevel } from '../../modules/logging'; +import { OdpEvent } from './odp_event'; +import { RequestHandler } from '../../utils/http_request_handler/http'; + +const EVENT_SENDING_FAILURE_MESSAGE = 'ODP event send failed'; + +/** + * Manager for communicating with the Optimizely Data Platform REST API + */ +export interface IRestApiManager { + sendEvents(apiKey: string, apiHost: string, events: OdpEvent[]): Promise; +} + +/** + * Concrete implementation for accessing the ODP REST API + */ +export class RestApiManager implements IRestApiManager { + private readonly logger: LogHandler; + private readonly requestHandler: RequestHandler; + + /** + * Creates instance to access Optimizely Data Platform (ODP) REST API + * @param requestHandler Desired request handler for testing + * @param logger Collect and record events/errors for this GraphQL implementation + */ + constructor(requestHandler: RequestHandler, logger: LogHandler) { + this.requestHandler = requestHandler; + this.logger = logger; + } + + /** + * Service for sending ODP events to REST API + * @param apiKey ODP public key + * @param apiHost Host of ODP endpoint + * @param events ODP events to send + * @returns Retry is true - if network or server error (5xx), otherwise false + */ + public async sendEvents(apiKey: string, apiHost: string, events: OdpEvent[]): Promise { + let shouldRetry = false; + + if (!apiKey || !apiHost) { + this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (Parameters apiKey or apiHost invalid)`); + return shouldRetry; + } + + if (events.length === 0) { + this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (no events)`); + return shouldRetry; + } + + const endpoint = `${apiHost}/v3/events`; + const data = JSON.stringify(events); + + const method = 'POST'; + const headers = { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }; + + let statusCode = 0; + try { + const request = this.requestHandler.makeRequest(endpoint, headers, method, data); + const response = await request.responsePromise; + statusCode = response.statusCode ?? statusCode; + } catch (err) { + let message = 'network error'; + if (err instanceof Error) { + message = (err as Error).message; + } + this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (${message})`); + shouldRetry = true; + } + + if (statusCode >= 400) { + this.logger.log(LogLevel.ERROR, `${EVENT_SENDING_FAILURE_MESSAGE} (${statusCode})`); + } + + if (statusCode >= 500) { + shouldRetry = true; + } + + return shouldRetry; + } +} diff --git a/packages/optimizely-sdk/lib/utils/enums/index.ts b/packages/optimizely-sdk/lib/utils/enums/index.ts index 5db60a894..38ea49a0c 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.ts +++ b/packages/optimizely-sdk/lib/utils/enums/index.ts @@ -291,3 +291,25 @@ export enum NOTIFICATION_TYPES { OPTIMIZELY_CONFIG_UPDATE = 'OPTIMIZELY_CONFIG_UPDATE', TRACK = 'TRACK:event_key, user_id, attributes, event_tags, event', } + +/** + * Default milliseconds before request timeout + */ +export const REQUEST_TIMEOUT_MS = 60 * 1000; // 1 minute + +/** + * ODP User Key + */ +export enum ODP_USER_KEY { + VUID = 'vuid', + FS_USER_ID = 'fs_user_id', +} + +/** + * Possible states of ODP integration + */ +export enum ODP_CONFIG_STATE { + UNDETERMINED = 0, + INTEGRATED, + NOT_INTEGRATED = 2, +} diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts index 3103b5322..3a2fe73f0 100644 --- a/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/browser_request_handler.ts @@ -15,41 +15,39 @@ */ import { AbortableRequest, Headers, RequestHandler, Response } from './http'; -import { REQUEST_TIMEOUT_MS } from './config'; import { LogHandler, LogLevel } from '../../modules/logging'; - -const READY_STATE_DONE = 4; +import { REQUEST_TIMEOUT_MS } from '../enums'; /** * Handles sending requests and receiving responses over HTTP via XMLHttpRequest */ export class BrowserRequestHandler implements RequestHandler { - private readonly _logger: LogHandler; - private readonly _timeout: number; + private readonly logger: LogHandler; + private readonly timeout: number; public constructor(logger: LogHandler, timeout: number = REQUEST_TIMEOUT_MS) { - this._logger = logger; - this._timeout = timeout; + this.logger = logger; + this.timeout = timeout; } /** * Builds an XMLHttpRequest - * @param reqUrl Fully-qualified URL to which to send the request + * @param requestUrl Fully-qualified URL to which to send the request * @param headers List of headers to include in the request * @param method HTTP method to use - * @param data stringified version of data to POST, PUT, etc + * @param data?? stringified version of data to POST, PUT, etc * @returns AbortableRequest contains both the response Promise and capability to abort() */ - public makeRequest(reqUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { + public makeRequest(requestUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { const request = new XMLHttpRequest(); const responsePromise: Promise = new Promise((resolve, reject) => { - request.open(method, reqUrl, true); + request.open(method, requestUrl, true); this.setHeadersInXhr(headers, request); request.onreadystatechange = (): void => { - if (request.readyState === READY_STATE_DONE) { + if (request.readyState === XMLHttpRequest.DONE) { const statusCode = request.status; if (statusCode === 0) { reject(new Error('Request error')); @@ -66,10 +64,10 @@ export class BrowserRequestHandler implements RequestHandler { } }; - request.timeout = this._timeout; + request.timeout = this.timeout; request.ontimeout = (): void => { - this._logger.log(LogLevel.WARNING, 'Request timed out'); + this.logger.log(LogLevel.WARNING, 'Request timed out'); }; request.send(data); @@ -124,7 +122,7 @@ export class BrowserRequestHandler implements RequestHandler { } } } catch { - this._logger.log(LogLevel.WARNING, `Unable to parse & skipped header item '${headerLine}'`); + this.logger.log(LogLevel.WARNING, `Unable to parse & skipped header item '${headerLine}'`); } }); return headers; diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/config.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/config.ts deleted file mode 100644 index 76ab92f82..000000000 --- a/packages/optimizely-sdk/lib/utils/http_request_handler/config.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright 2019-2020, 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 - * - * 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. - */ - -/** - * Default milliseconds before request timeout - */ -export const REQUEST_TIMEOUT_MS = 60 * 1000; // 1 minute diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts index 4eafb07fd..98c1cedc6 100644 --- a/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/http.ts @@ -43,5 +43,5 @@ export interface AbortableRequest { * Client that handles sending requests and receiving responses */ export interface RequestHandler { - makeRequest(reqUrl: string, headers: Headers, method: string, data?: string): AbortableRequest; + makeRequest(requestUrl: string, headers: Headers, method: string, data?: string): AbortableRequest; } diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts index 5a6c647f1..3de55a9e7 100644 --- a/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts +++ b/packages/optimizely-sdk/lib/utils/http_request_handler/node_request_handler.ts @@ -18,32 +18,32 @@ import http from 'http'; import https from 'https'; import url from 'url'; import { AbortableRequest, Headers, RequestHandler, Response } from './http'; -import { REQUEST_TIMEOUT_MS } from './config'; import decompressResponse from 'decompress-response'; import { LogHandler } from '../../modules/logging'; +import { REQUEST_TIMEOUT_MS } from '../enums'; /** * Handles sending requests and receiving responses over HTTP via NodeJS http module */ export class NodeRequestHandler implements RequestHandler { - private readonly _logger: LogHandler; - private readonly _timeout: number; + private readonly logger: LogHandler; + private readonly timeout: number; public constructor(logger: LogHandler, timeout: number = REQUEST_TIMEOUT_MS) { - this._logger = logger; - this._timeout = timeout; + this.logger = logger; + this.timeout = timeout; } /** * Builds an XMLHttpRequest - * @param reqUrl Fully-qualified URL to which to send the request + * @param requestUrl Fully-qualified URL to which to send the request * @param headers List of headers to include in the request * @param method HTTP method to use - * @param data stringified version of data to POST, PUT, etc + * @param data? stringified version of data to POST, PUT, etc * @returns AbortableRequest contains both the response Promise and capability to abort() */ - public makeRequest(reqUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { - const parsedUrl = url.parse(reqUrl); + public makeRequest(requestUrl: string, headers: Headers, method: string, data?: string): AbortableRequest { + const parsedUrl = url.parse(requestUrl); if (parsedUrl.protocol !== 'https:') { return { @@ -60,7 +60,7 @@ export class NodeRequestHandler implements RequestHandler { ...headers, 'accept-encoding': 'gzip,deflate', }, - timeout: this._timeout, + timeout: this.timeout, }); const responsePromise = this.getResponseFromRequest(request); @@ -170,8 +170,3 @@ export class NodeRequestHandler implements RequestHandler { }); } } - - - - - diff --git a/packages/optimizely-sdk/lib/utils/http_request_handler/request_handler_factory.ts b/packages/optimizely-sdk/lib/utils/http_request_handler/request_handler_factory.ts deleted file mode 100644 index ddab23faf..000000000 --- a/packages/optimizely-sdk/lib/utils/http_request_handler/request_handler_factory.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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. - */ - -import { LogHandler } from '../../modules/logging'; -import { RequestHandler } from './http'; -import { NodeRequestHandler } from './node_request_handler'; -import { BrowserRequestHandler } from './browser_request_handler'; - -/** - * Factory to create the appropriate type of RequestHandler based on a provided context - */ -export class RequestHandlerFactory { - public static createHandler(logger: LogHandler, timeout?: number): RequestHandler { - if (window) { - return new BrowserRequestHandler(logger, timeout); - } else if (process) { - return new NodeRequestHandler(logger, timeout); - } else { - return null as unknown as RequestHandler; - } - } -} diff --git a/packages/optimizely-sdk/tests/graphQLManager.spec.ts b/packages/optimizely-sdk/tests/graphQLManager.spec.ts deleted file mode 100644 index 4a877c1bc..000000000 --- a/packages/optimizely-sdk/tests/graphQLManager.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * 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. - */ - -/// - -import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; -import { IOdpClient, OdpClient } from '../lib/plugins/odp/odp_client'; -import { ErrorHandler, LogHandler, LogLevel } from '../lib/modules/logging'; -import { GraphqlManager } from '../lib/plugins/odp/graphql_manager'; -import { Response } from '../lib/plugins/odp/odp_types'; - -describe('GraphQLManager', () => { - const VALID_ODP_PUBLIC_KEY = 'not-real-api-key'; - const ODP_GRAPHQL_URL = 'https://some.example.com/graphql/endpoint'; - const FS_USER_ID = 'fs_user_id'; - const VALID_FS_USER_ID = 'tester-101'; - const SEGMENTS_TO_CHECK = [ - 'has_email', - 'has_email_opted_in', - 'push_on_sale', - ]; - - const makeManagerInstance = () => new GraphqlManager(instance(mockErrorHandler), instance(mockLogger), instance(mockOdpClient)); - - let mockErrorHandler: ErrorHandler; - let mockLogger: LogHandler; - let mockOdpClient: IOdpClient; - - beforeAll(() => { - mockErrorHandler = mock(); - mockLogger = mock(); - mockOdpClient = mock(); - }); - - beforeEach(() => { - resetCalls(mockErrorHandler); - resetCalls(mockLogger); - resetCalls(mockOdpClient); - }); - - it('should parse a successful response', () => { - const validJsonResponse = `{ - "data": { - "customer": { - "audiences": { - "edges": [ - { - "node": { - "name": "has_email", - "state": "qualified" - } - }, - { - "node": { - "name": "has_email_opted_in", - "state": "not-qualified" - } - } - ] - } - } - } - }`; - const manager = makeManagerInstance(); - - const response = manager['parseSegmentsResponseJson'](validJsonResponse) as Response; - - expect(response).not.toBeUndefined(); - expect(response?.errors).toHaveLength(0); - expect(response?.data?.customer?.audiences?.edges).not.toBeNull(); - expect(response.data.customer.audiences.edges).toHaveLength(2); - let node = response.data.customer.audiences.edges[0].node; - expect(node.name).toEqual('has_email'); - expect(node.state).toEqual('qualified'); - node = response.data.customer.audiences.edges[1].node; - expect(node.name).toEqual('has_email_opted_in'); - expect(node.state).not.toEqual('qualified'); - }); - - it('should parse an error response', () => { - const errorJsonResponse = `{ - "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 - } -}`; - const manager = makeManagerInstance(); - - const response = manager['parseSegmentsResponseJson'](errorJsonResponse) as Response; - - expect(response).not.toBeUndefined(); - expect(response.data.customer).toBeNull(); - expect(response?.errors).not.toBeNull(); - expect(response.errors[0].extensions.classification).toEqual('InvalidIdentifierException'); - }); - - it('should fetch valid qualified segments', async () => { - const responseJsonWithQualifiedSegments = '{"data":{"customer":{"audiences":' + - '{"edges":[{"node":{"name":"has_email",' + - '"state":"qualified"}},{"node":{"name":' + - '"has_email_opted_in","state":"qualified"}}]}}}}'; - when(mockOdpClient.querySegments(anything())).thenResolve(responseJsonWithQualifiedSegments); - const manager = makeManagerInstance(); - - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(2); - expect(segments).toContain('has_email'); - expect(segments).toContain('has_email_opted_in'); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('should handle empty qualified segments', async () => { - const responseJsonWithNoQualifiedSegments = '{"data":{"customer":{"audiences":' + - '{"edges":[ ]}}}}'; - when(mockOdpClient.querySegments(anything())).thenResolve(responseJsonWithNoQualifiedSegments); - const manager = makeManagerInstance(); - - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(0); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('should handle error with invalid identifier', async () => { - const INVALID_USER_ID = 'invalid-user'; - const errorJsonResponse = '{"errors":[{"message":' + - '"Exception while fetching data (/customer) : ' + - `Exception: could not resolve _fs_user_id = ${INVALID_USER_ID}",` + - '"locations":[{"line":1,"column":8}],"path":["customer"],' + - '"extensions":{"classification":"DataFetchingException"}}],' + - '"data":{"customer":null}}'; - when(mockOdpClient.querySegments(anything())).thenResolve(errorJsonResponse); - const manager = makeManagerInstance(); - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, INVALID_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(0); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(anything(), anyString())).once(); - }); - - it('should handle unrecognized JSON responses', async () => { - const unrecognizedJson = '{"unExpectedObject":{ "withSome": "value", "thatIsNotParseable": "true" }}'; - when(mockOdpClient.querySegments(anything())).thenResolve(unrecognizedJson); - const manager = makeManagerInstance(); - - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(0); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); - }); - - it('should handle other exception types', async () => { - const errorJsonResponse = '{"errors":[{"message":"Validation error of type ' + - 'UnknownArgument: Unknown field argument not_real_userKey @ ' + - '\'customer\'","locations":[{"line":1,"column":17}],' + - '"extensions":{"classification":"ValidationError"}}]}'; - when(mockOdpClient.querySegments(anything())).thenResolve(errorJsonResponse); - const manager = makeManagerInstance(); - - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(0); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(anything(), anyString())).once(); - }); - - it('should handle bad responses', async () => { - const badResponse = '{"data":{ }}'; - when(mockOdpClient.querySegments(anything())).thenResolve(badResponse); - const manager = makeManagerInstance(); - - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(0); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); - }); - - it('should handle non 200 HTTP status code response', async () => { - when(mockOdpClient.querySegments(anything())).thenResolve(null); - const manager = makeManagerInstance(); - - const segments = await manager.fetchSegments(VALID_ODP_PUBLIC_KEY, ODP_GRAPHQL_URL, FS_USER_ID, VALID_FS_USER_ID, SEGMENTS_TO_CHECK); - - expect(segments).toHaveLength(0); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); - }); -}); - diff --git a/packages/optimizely-sdk/tests/graphQlManager.spec.ts b/packages/optimizely-sdk/tests/graphQlManager.spec.ts new file mode 100644 index 000000000..8f2c228ff --- /dev/null +++ b/packages/optimizely-sdk/tests/graphQlManager.spec.ts @@ -0,0 +1,258 @@ +/** + * 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. + */ + +/// + +import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { LogHandler, LogLevel } from '../lib/modules/logging'; +import { GraphQLManager } from '../lib/plugins/odp/graphql_manager'; +import { RequestHandler } from '../lib/utils/http_request_handler/http'; +import { ODP_USER_KEY } from '../lib/utils/enums'; + +const API_key = 'not-real-api-key'; +const GRAPHQL_ENDPOINT = 'https://some.example.com/graphql/endpoint'; +const USER_KEY = ODP_USER_KEY.FS_USER_ID; +const USER_VALUE = 'tester-101'; +const SEGMENTS_TO_CHECK = [ + 'has_email', + 'has_email_opted_in', + 'push_on_sale', +]; + +describe('GraphQLManager', () => { + let mockLogger: LogHandler; + let mockRequestHandler: RequestHandler; + + beforeAll(() => { + mockLogger = mock(); + mockRequestHandler = mock(); + }); + + beforeEach(() => { + resetCalls(mockLogger); + resetCalls(mockRequestHandler); + }); + + const managerInstance = () => new GraphQLManager(instance(mockRequestHandler), instance(mockLogger)); + + const abortableRequest = (statusCode: number, body: string) => { + return { + abort: () => { + }, + responsePromise: Promise.resolve({ + statusCode, + body, + headers: {}, + }), + }; + }; + + it('should parse a successful response', () => { + const validJsonResponse = `{ + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "has_email", + "state": "qualified" + } + }, + { + "node": { + "name": "has_email_opted_in", + "state": "not-qualified" + } + } + ] + } + } + } + }`; + const manager = managerInstance(); + + const response = manager['parseSegmentsResponseJson'](validJsonResponse); + + expect(response).not.toBeUndefined(); + expect(response?.errors).toHaveLength(0); + expect(response?.data?.customer?.audiences?.edges).not.toBeNull(); + expect(response?.data.customer.audiences.edges).toHaveLength(2); + let node = response?.data.customer.audiences.edges[0].node; + expect(node?.name).toEqual('has_email'); + expect(node?.state).toEqual('qualified'); + node = response?.data.customer.audiences.edges[1].node; + expect(node?.name).toEqual('has_email_opted_in'); + expect(node?.state).not.toEqual('qualified'); + }); + + it('should parse an error response', () => { + const errorJsonResponse = `{ + "errors": [ + { + "message": "Exception while fetching data (/customer) : Exception: could not resolve _fs_user_id = mock_user_id", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "customer" + ], + "extensions": { + "classification": "InvalidIdentifierException" + } + } + ], + "data": { + "customer": null + } +}`; + const manager = managerInstance(); + + const response = manager['parseSegmentsResponseJson'](errorJsonResponse); + + expect(response).not.toBeUndefined(); + expect(response?.data.customer).toBeNull(); + expect(response?.errors).not.toBeNull(); + expect(response?.errors[0].extensions.classification).toEqual('InvalidIdentifierException'); + }); + + it('should construct a valid GraphQL query string', () => { + const manager = managerInstance(); + + const response = manager['toGraphQLJson'](USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(response) + .toBe(`{"query" : "query {customer"(${USER_KEY} : "${USER_VALUE}") {audiences(subset: [\\"has_email\\",\\"has_email_opted_in\\",\\"push_on_sale\\"] {edges {node {name state}}}}}"}`, + ); + }); + + it('should fetch valid qualified segments', async () => { + const responseJsonWithQualifiedSegments = '{"data":{"customer":{"audiences":' + + '{"edges":[{"node":{"name":"has_email",' + + '"state":"qualified"}},{"node":{"name":' + + '"has_email_opted_in","state":"qualified"}}]}}}}'; + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, responseJsonWithQualifiedSegments)); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(2); + expect(segments).toContain('has_email'); + expect(segments).toContain('has_email_opted_in'); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('should handle a request to query no segments', async () => { + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, ODP_USER_KEY.FS_USER_ID, USER_VALUE, []); + + expect(segments).toHaveLength(0); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('should handle empty qualified segments', async () => { + const responseJsonWithNoQualifiedSegments = '{"data":{"customer":{"audiences":' + + '{"edges":[ ]}}}}'; + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, responseJsonWithNoQualifiedSegments)); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toHaveLength(0); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('should handle error with invalid identifier', async () => { + const INVALID_USER_ID = 'invalid-user'; + const errorJsonResponse = '{"errors":[{"message":' + + '"Exception while fetching data (/customer) : ' + + `Exception: could not resolve _fs_user_id = ${INVALID_USER_ID}",` + + '"locations":[{"line":1,"column":8}],"path":["customer"],' + + '"extensions":{"classification":"DataFetchingException"}}],' + + '"data":{"customer":null}}'; + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, errorJsonResponse)); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, INVALID_USER_ID, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + verify(mockLogger.log(anything(), anyString())).once(); + }); + + it('should handle unrecognized JSON responses', async () => { + const unrecognizedJson = '{"unExpectedObject":{ "withSome": "value", "thatIsNotParseable": "true" }}'; + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, unrecognizedJson)); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); + }); + + it('should handle other exception types', async () => { + const errorJsonResponse = '{"errors":[{"message":"Validation error of type ' + + 'UnknownArgument: Unknown field argument not_real_userKey @ ' + + '\'customer\'","locations":[{"line":1,"column":17}],' + + '"extensions":{"classification":"ValidationError"}}]}'; + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, errorJsonResponse)); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + verify(mockLogger.log(anything(), anyString())).once(); + }); + + it('should handle bad responses', async () => { + const badResponse = '{"data":{ }}'; + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, badResponse)); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (decode error)')).once(); + }); + + it('should handle non 200 HTTP status code response', async () => { + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(400, '')); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); + }); + + it('should handle a timeout', async () => { + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject(new Error('Request timed out')), + }); + const manager = managerInstance(); + + const segments = await manager.fetchSegments(API_key, GRAPHQL_ENDPOINT, USER_KEY, USER_VALUE, SEGMENTS_TO_CHECK); + + expect(segments).toBeNull(); + verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); + }); +}); diff --git a/packages/optimizely-sdk/tests/odpClient.spec.ts b/packages/optimizely-sdk/tests/odpClient.spec.ts deleted file mode 100644 index d0a7bbb57..000000000 --- a/packages/optimizely-sdk/tests/odpClient.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * 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. - */ - -/// - -import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; -import { ErrorHandler, LogHandler, LogLevel } from '../lib/modules/logging'; -import { OdpClient } from '../lib/plugins/odp/odp_client'; -import { QuerySegmentsParameters } from '../lib/plugins/odp/query_segments_parameters'; -import { BrowserRequestHandler } from '../lib/utils/http_request_handler/browser_request_handler'; -import { NodeRequestHandler } from '../lib/utils/http_request_handler/node_request_handler'; - -describe('OdpClient', () => { - const MOCK_QUERY_PARAMETERS = new QuerySegmentsParameters({ - apiKey: 'not-real-api-key', - apiHost: 'https://api.example.com/v3/graphql', - userKey: 'fs_user_id', - userValue: 'mock-user-id', - segmentsToCheck: [ - 'has_email', - 'has_email_opted_in', - 'push_on_sale', - ], - }); - const VALID_RESPONSE_JSON = { - 'data': { - 'customer': { - 'audiences': { - 'edges': [ - { - 'node': { - 'name': 'has_email', - 'state': 'qualified', - }, - }, - { - 'node': { - 'name': 'has_email_opted_in', - 'state': 'qualified', - }, - }, - ], - }, - }, - }, - }; - - let mockErrorHandler: ErrorHandler; - let mockLogger: LogHandler; - let mockBrowserRequestHandler: BrowserRequestHandler; - let mockNodeRequestHandler: NodeRequestHandler; - - beforeAll(() => { - mockErrorHandler = mock(); - mockLogger = mock(); - mockBrowserRequestHandler = mock(); - mockNodeRequestHandler = mock(); - }); - - beforeEach(() => { - resetCalls(mockErrorHandler); - resetCalls(mockLogger); - resetCalls(mockBrowserRequestHandler); - resetCalls(mockNodeRequestHandler); - }); - - it('should handle missing API Host', async () => { - const missingApiHost = new QuerySegmentsParameters({ - apiKey: 'apiKey', - apiHost: '', - userKey: 'userKey', - userValue: 'userValue', - segmentsToCheck: ['segmentToCheck'], - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); - - await client.querySegments(missingApiHost); - - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(LogLevel.ERROR, 'No ApiHost or ApiKey set before querying segments')).once(); - }); - - it('should handle missing API Key', async () => { - const missingApiHost = new QuerySegmentsParameters({ - apiKey: '', - apiHost: 'apiHost', - userKey: 'userKey', - userValue: 'userValue', - segmentsToCheck: ['segmentToCheck'], - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); - - await client.querySegments(missingApiHost); - - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(LogLevel.ERROR, 'No ApiHost or ApiKey set before querying segments')).once(); - }); - - it('Browser: should get mocked segments successfully', async () => { - when(mockBrowserRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.resolve({ - statusCode: 200, - body: JSON.stringify(VALID_RESPONSE_JSON), - headers: {}, - }), - }); - - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockBrowserRequestHandler)); - - const response = await client.querySegments(MOCK_QUERY_PARAMETERS) ?? ''; - - expect(response).toEqual(JSON.stringify(VALID_RESPONSE_JSON)); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('Node: should get mocked segments successfully', async () => { - when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.resolve({ - statusCode: 200, - body: JSON.stringify(VALID_RESPONSE_JSON), - headers: {}, - }), - }); - - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); - - const response = await client.querySegments(MOCK_QUERY_PARAMETERS) ?? ''; - - expect(response).toEqual(JSON.stringify(VALID_RESPONSE_JSON)); - verify(mockErrorHandler.handleError(anything())).never(); - verify(mockLogger.log(anything(), anyString())).never(); - }); - - it('Browser should handle 400 HTTP response', async () => { - when(mockBrowserRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.reject({ - statusCode: 400, - body: '', - headers: {}, - }), - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockBrowserRequestHandler)); - - const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); - - expect(responseJson).toBeNull(); - verify(mockErrorHandler.handleError(anything())).once(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (400)')).once(); - }); - - it('Node should handle 400 HTTP response', async () => { - when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.reject({ - statusCode: 400, - body: '', - headers: {}, - }), - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); - - const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); - - expect(responseJson).toBeNull(); - verify(mockErrorHandler.handleError(anything())).once(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (400)')).once(); - }); - - it('Browser should handle 500 HTTP response', async () => { - when(mockBrowserRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.reject({ - statusCode: 500, - body: '', - headers: {}, - }), - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockBrowserRequestHandler)); - - const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); - - expect(responseJson).toBeNull(); - verify(mockErrorHandler.handleError(anything())).once(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (500)')).once(); - }); - - it('Node should handle 500 HTTP response', async () => { - when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.reject({ - statusCode: 500, - body: '', - headers: {}, - }), - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler)); - - const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); - - expect(responseJson).toBeNull(); - verify(mockErrorHandler.handleError(anything())).once(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (500)')).once(); - }); - - it('should handle a network timeout', async () => { - when(mockNodeRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ - abort: () => { - }, - responsePromise: Promise.reject(new Error('Request timed out')), - }); - const client = new OdpClient(instance(mockErrorHandler), instance(mockLogger), instance(mockNodeRequestHandler), 10); - - const responseJson = await client.querySegments(MOCK_QUERY_PARAMETERS); - - expect(responseJson).toBeNull(); - verify(mockErrorHandler.handleError(anything())).once(); - verify(mockLogger.log(LogLevel.ERROR, 'Audience segments fetch failed (network error)')).once(); - }); -}); - diff --git a/packages/optimizely-sdk/tests/restApiManager.spec.ts b/packages/optimizely-sdk/tests/restApiManager.spec.ts new file mode 100644 index 000000000..132649da7 --- /dev/null +++ b/packages/optimizely-sdk/tests/restApiManager.spec.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/// + +import { anyString, anything, instance, mock, resetCalls, verify, when } from 'ts-mockito'; +import { LogHandler, LogLevel } from '../lib/modules/logging'; +import { RestApiManager } from '../lib/plugins/odp/rest_api_manager'; +import { OdpEvent } from '../lib/plugins/odp/odp_event'; +import { RequestHandler } from '../lib/utils/http_request_handler/http'; + +const VALID_ODP_PUBLIC_KEY = 'not-real-api-key'; +const ODP_REST_API_HOST = 'https://events.example.com/v2/api'; +const data1 = new Map(); +data1.set('key11', 'value-1'); +data1.set('key12', true); +data1.set('key12', 3.5); +data1.set('key14', null); +const data2 = new Map(); +data2.set('key2', 'value-2'); +const ODP_EVENTS = [ + new OdpEvent('t1', 'a1', + new Map([['id-key-1', 'id-value-1']]), + data1), + new OdpEvent('t2', 'a2', + new Map([['id-key-2', 'id-value-2']]), + data2), +]; + +describe('RestApiManager', () => { + let mockLogger: LogHandler; + let mockRequestHandler: RequestHandler; + + beforeAll(() => { + mockLogger = mock(); + mockRequestHandler = mock(); + }); + + beforeEach(() => { + resetCalls(mockLogger); + resetCalls(mockRequestHandler); + }); + + const managerInstance = () => new RestApiManager(instance(mockRequestHandler), instance(mockLogger)); + const abortableRequest = (statusCode: number, body: string) => { + return { + abort: () => { + }, + responsePromise: Promise.resolve({ + statusCode, + body, + headers: {}, + }), + }; + }; + + it('should should send events successfully and not suggest retry', async () => { + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(200, '')); + const manager = managerInstance(); + + const shouldRetry = await manager.sendEvents(VALID_ODP_PUBLIC_KEY, ODP_REST_API_HOST, ODP_EVENTS); + + expect(shouldRetry).toBe(false); + verify(mockLogger.log(anything(), anyString())).never(); + }); + + it('should not suggest a retry for 400 HTTP response', async () => { + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(400, '')); + const manager = managerInstance(); + + const shouldRetry = await manager.sendEvents(VALID_ODP_PUBLIC_KEY, ODP_REST_API_HOST, ODP_EVENTS); + + expect(shouldRetry).toBe(false); + verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (400)')).once(); + }); + + it('should suggest a retry for 500 HTTP response', async () => { + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn(abortableRequest(500, '')); + const manager = managerInstance(); + + const shouldRetry = await manager.sendEvents(VALID_ODP_PUBLIC_KEY, ODP_REST_API_HOST, ODP_EVENTS); + + expect(shouldRetry).toBe(true); + verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (500)')).once(); + }); + + it('should suggest a retry for network timeout', async () => { + when(mockRequestHandler.makeRequest(anything(), anything(), anything(), anything())).thenReturn({ + abort: () => { + }, + responsePromise: Promise.reject(new Error('Request timed out')), + }); + const manager = managerInstance(); + + const shouldRetry = await manager.sendEvents(VALID_ODP_PUBLIC_KEY, ODP_REST_API_HOST, ODP_EVENTS); + + expect(shouldRetry).toBe(true); + verify(mockLogger.log(LogLevel.ERROR, 'ODP event send failed (Request timed out)')).once(); + }); +});