diff --git a/jest.config.js b/jest.config.js index 5b0cd4cfa9..0abe6622af 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,11 @@ module.exports = { transform: {'^.+\\.ts?$': 'ts-jest'}, + testMatch: ["**/__tests__/**/*test.ts?(x)"], testEnvironment: 'node', - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: [ + "platform-node/src/**/*.ts", + "sdk-common/src/**/*.ts", + "server-sdk-common/src/**/*.ts" + ] }; diff --git a/platform-node/__tests__/NodeInfo_test.ts b/platform-node/__tests__/NodeInfo.test.ts similarity index 100% rename from platform-node/__tests__/NodeInfo_test.ts rename to platform-node/__tests__/NodeInfo.test.ts diff --git a/platform-node/__tests__/NodeRequests_test.ts b/platform-node/__tests__/NodeRequests.test.ts similarity index 100% rename from platform-node/__tests__/NodeRequests_test.ts rename to platform-node/__tests__/NodeRequests.test.ts diff --git a/platform-node/tsconfig.json b/platform-node/tsconfig.json index bddab03ff1..171b58e8a8 100644 --- a/platform-node/tsconfig.json +++ b/platform-node/tsconfig.json @@ -15,5 +15,6 @@ "declaration": true, "declarationMap": true, // enables importers to jump to source "resolveJsonModule": true, + "stripInternal": true }, } diff --git a/sdk-common/tsconfig.json b/sdk-common/tsconfig.json index 0c10db0c9d..4b913cbe13 100644 --- a/sdk-common/tsconfig.json +++ b/sdk-common/tsconfig.json @@ -14,5 +14,6 @@ "sourceMap": true, "declaration": true, "declarationMap": true, // enables importers to jump to source + "stripInternal": true } } diff --git a/server-sdk-common/__tests__/Logger.ts b/server-sdk-common/__tests__/Logger.ts new file mode 100644 index 0000000000..0082fa2cee --- /dev/null +++ b/server-sdk-common/__tests__/Logger.ts @@ -0,0 +1,105 @@ +import { LDLogger } from '../src'; + +// TODO: Move this to sdk-common when implementing logging. +export enum LogLevel { + Debug, + Info, + Warn, + Error, +} + +type ExpectedMessage = { level: LogLevel, matches: RegExp }; + +export default class TestLogger implements LDLogger { + private readonly messages: Record = { + [LogLevel.Debug]: [], + [LogLevel.Info]: [], + [LogLevel.Warn]: [], + [LogLevel.Error]: [], + }; + + private callCount = 0; + + private waiters: Array<() => void> = []; + + timeout(timeoutMs: number): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(this.callCount), timeoutMs); + }); + } + + async waitForMessages(count: number, timeoutMs: number = 1000): Promise { + return Promise.race([ + new Promise((resolve) => { + const waiter = () => { + if (this.callCount >= count) { + resolve(this.callCount); + } + }; + waiter(); + this.waiters.push(waiter); + }), this.timeout(timeoutMs)]); + } + + /** + * Check received messages for expected messages. + * + * @param expectedMessages List of expected messages. If a message is expected + * more than once, then it should be included multiple times. + * @returns A list of messages that were not received. + */ + expectMessages( + expectedMessages: ExpectedMessage[], + ): void { + const matched: Record = { + [LogLevel.Debug]: [], + [LogLevel.Info]: [], + [LogLevel.Warn]: [], + [LogLevel.Error]: [], + }; + + expectedMessages.forEach((expectedMessage) => { + const received = this.messages[expectedMessage.level]; + const index = received.findIndex( + (receivedMessage) => receivedMessage.match(expectedMessage.matches), + ); + if (index < 0) { + throw new Error(`Did not find expected message: ${expectedMessage}`); + } else if (matched[expectedMessage.level].indexOf(index) >= 0) { + throw new Error(`Did not find expected message: ${expectedMessage}`); + } else { + matched[expectedMessage.level].push(index); + } + }); + } + + getCount() { + return this.callCount; + } + + private checkResolves() { + this.waiters.forEach((waiter) => waiter()); + } + + private log(level: LogLevel, ...args: any[]) { + this.messages[level].push(args.join(' ')); + this.callCount += 1; + this.checkResolves(); + } + + error(...args: any[]): void { + this.log(LogLevel.Error, args); + } + + warn(...args: any[]): void { + this.log(LogLevel.Warn, args); + } + + info(...args: any[]): void { + this.log(LogLevel.Info, args); + } + + debug(...args: any[]): void { + this.log(LogLevel.Debug, args); + } +} diff --git a/server-sdk-common/__tests__/options/ApplicationTags.test.ts b/server-sdk-common/__tests__/options/ApplicationTags.test.ts new file mode 100644 index 0000000000..6aae7e69e8 --- /dev/null +++ b/server-sdk-common/__tests__/options/ApplicationTags.test.ts @@ -0,0 +1,53 @@ +import ApplicationTags from '../../src/options/ApplicationTags'; +import { ValidatedOptions } from '../../src/options/ValidatedOptions'; +import TestLogger, { LogLevel } from '../Logger'; + +describe.each([ + [ + { application: { id: 'is-valid', version: 'also-valid' }, logger: new TestLogger() }, + 'application-id/is-valid application-version/also-valid', [], + ], + [{ application: { id: 'is-valid' }, logger: new TestLogger() }, 'application-id/is-valid', []], + [{ application: { version: 'also-valid' }, logger: new TestLogger() }, 'application-version/also-valid', []], + [{ application: {}, logger: new TestLogger() }, undefined, []], + [{ logger: new TestLogger() }, undefined, []], + [undefined, undefined, undefined], + + // Above ones are 'valid' cases. Below are invalid. + [ + { application: { id: 'bad tag' }, logger: new TestLogger() }, + undefined, [ + { level: LogLevel.Warn, matches: /Config option "application.id" must/ }, + ], + ], + [ + { application: { id: 'bad tag', version: 'good-tag' }, logger: new TestLogger() }, + 'application-version/good-tag', [ + { level: LogLevel.Warn, matches: /Config option "application.id" must/ }, + ], + ], + [ + { application: { id: 'bad tag', version: 'also bad' }, logger: new TestLogger() }, + undefined, [ + { level: LogLevel.Warn, matches: /Config option "application.id" must/ }, + { level: LogLevel.Warn, matches: /Config option "application.version" must/ }, + ], + ], + // Bad tags and no logger. + [ + { application: { id: 'bad tag', version: 'also bad' }, logger: undefined }, + undefined, undefined, + ], +])('given application tags configurations', (config, result, logs) => { + it('produces the correct tag values', () => { + const tags = new ApplicationTags(config as unknown as ValidatedOptions); + expect(tags.value).toEqual(result); + }); + + it('logs issues it encounters', () => { + expect(config?.logger?.getCount()).toEqual(logs?.length); + if (logs) { + config?.logger?.expectMessages(logs); + } + }); +}); diff --git a/server-sdk-common/__tests__/options/Configuration.test.ts b/server-sdk-common/__tests__/options/Configuration.test.ts new file mode 100644 index 0000000000..8e0ba8cdb8 --- /dev/null +++ b/server-sdk-common/__tests__/options/Configuration.test.ts @@ -0,0 +1,324 @@ +import { LDOptions } from '../../src'; +import Configuration from '../../src/options/Configuration'; +import TestLogger, { LogLevel } from '../Logger'; + +function withLogger(options: LDOptions): LDOptions { + return { ...options, logger: new TestLogger() }; +} + +function logger(options: LDOptions): TestLogger { + return options.logger as TestLogger; +} + +describe.each([ + undefined, null, 'potat0', 17, [], {}, +])('constructed without options', (input) => { + it('should have default options', () => { + // JavaScript is not going to stop you from calling this with whatever + // you want. So we need to tell TS to ingore our bad behavior. + // @ts-ignore + const config = new Configuration(input); + + expect(config.allAttributesPrivate).toBe(false); + expect(config.contextKeysCapacity).toBe(1000); + expect(config.contextKeysFlushInterval).toBe(300); + expect(config.diagnosticOptOut).toBe(false); + expect(config.eventsCapacity).toBe(10000); + expect(config.flushInterval).toBe(5); + expect(config.logger).toBeUndefined(); + expect(config.offline).toBe(false); + expect(config.pollInterval).toBe(30); + expect(config.privateAttributes).toStrictEqual([]); + expect(config.proxyOptions).toBeUndefined(); + expect(config.sendEvents).toBe(true); + expect(config.serviceEndpoints.streaming).toEqual('https://stream.launchdarkly.com'); + expect(config.serviceEndpoints.polling).toEqual('https://sdk.launchdarkly.com'); + expect(config.serviceEndpoints.events).toEqual('https://events.launchdarkly.com'); + expect(config.stream).toBe(true); + expect(config.streamInitialReconnectDelay).toEqual(1); + expect(config.tags.value).toBeUndefined(); + expect(config.timeout).toEqual(5); + expect(config.tlsParams).toBeUndefined(); + expect(config.useLdd).toBe(false); + expect(config.wrapperName).toBeUndefined(); + expect(config.wrapperVersion).toBeUndefined(); + }); +}); + +describe('when setting different options', () => { + it.each([ + ['http://cats.launchdarkly.com', 'http://cats.launchdarkly.com', [ + { level: LogLevel.Warn, matches: /You have set custom uris without.* streamUri/ }, + { level: LogLevel.Warn, matches: /You have set custom uris without.* eventsUri/ }, + ]], + ['http://cats.launchdarkly.com/', 'http://cats.launchdarkly.com', [ + { level: LogLevel.Warn, matches: /You have set custom uris without.* streamUri/ }, + { level: LogLevel.Warn, matches: /You have set custom uris without.* eventsUri/ }, + ]], + [0, 'https://sdk.launchdarkly.com', [ + { level: LogLevel.Warn, matches: /Config option "baseUri" should be of type/ }, + { level: LogLevel.Warn, matches: /You have set custom uris without.* streamUri/ }, + { level: LogLevel.Warn, matches: /You have set custom uris without.* eventsUri/ }, + ]], + ])('allows setting the baseUri and validates the baseUri', (uri, expected, logs) => { + // @ts-ignore + const config = new Configuration(withLogger({ baseUri: uri })); + expect(config.serviceEndpoints.polling).toEqual(expected); + expect(logger(config).getCount()).toEqual(logs.length); + // There should not be any messages, so checking them for undefined is a workaround + // for a lack of pure assert. + logger(config).expectMessages(logs); + }); + + it.each([ + ['http://cats.launchdarkly.com', 'http://cats.launchdarkly.com', 2], + ['http://cats.launchdarkly.com/', 'http://cats.launchdarkly.com', 2], + [0, 'https://stream.launchdarkly.com', 3], + ])('allows setting the streamUri and validates the streamUri', (uri, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ streamUri: uri })); + expect(config.serviceEndpoints.streaming).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + ['http://cats.launchdarkly.com', 'http://cats.launchdarkly.com', 2], + ['http://cats.launchdarkly.com/', 'http://cats.launchdarkly.com', 2], + [0, 'https://events.launchdarkly.com', 3], + ])('allows setting the eventsUri and validates the eventsUri', (uri, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ eventsUri: uri })); + expect(config.serviceEndpoints.events).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it('produces no logs when setting all URLs.', () => { + // @ts-ignore + const config = new Configuration(withLogger({ eventsUri: 'cats', baseUri: 'cats', streamUri: 'cats' })); + expect(config.serviceEndpoints.events).toEqual('cats'); + expect(config.serviceEndpoints.streaming).toEqual('cats'); + expect(config.serviceEndpoints.polling).toEqual('cats'); + expect(logger(config).getCount()).toEqual(0); + }); + + it('Does not log a warning for the events URI if sendEvents is false..', () => { + // @ts-ignore + const config = new Configuration(withLogger({ sendEvents: false, baseUri: 'cats', streamUri: 'cats' })); + expect(config.serviceEndpoints.streaming).toEqual('cats'); + expect(config.serviceEndpoints.polling).toEqual('cats'); + expect(logger(config).getCount()).toEqual(0); + }); + + it('Does log a warning for the events URI if sendEvents is true..', () => { + // @ts-ignore + const config = new Configuration(withLogger({ sendEvents: true, baseUri: 'cats', streamUri: 'cats' })); + expect(config.serviceEndpoints.streaming).toEqual('cats'); + expect(config.serviceEndpoints.polling).toEqual('cats'); + expect(logger(config).getCount()).toEqual(1); + }); + + it.each([ + [0, 0, []], + [6, 6, []], + ['potato', 5, [ + { level: LogLevel.Warn, matches: /Config option "timeout" should be of type/ }, + ]], + ])('allow setting timeout and validates timeout', (value, expected, logs) => { + // @ts-ignore + const config = new Configuration(withLogger({ timeout: value })); + expect(config.timeout).toEqual(expected); + logger(config).expectMessages(logs); + }); + + it.each([ + [0, 0, []], + [6, 6, []], + ['potato', 10000, [ + { level: LogLevel.Warn, matches: /Config option "capacity" should be of type/ }, + ]], + ])('allow setting and validates capacity', (value, expected, logs) => { + // @ts-ignore + const config = new Configuration(withLogger({ capacity: value })); + expect(config.eventsCapacity).toEqual(expected); + logger(config).expectMessages(logs); + }); + + it.each([ + [0, 0, 0], + [6, 6, 0], + ['potato', 5, 1], + ])('allow setting and validates flushInterval', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ flushInterval: value })); + expect(config.flushInterval).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [0, 30, 1], + [500, 500, 0], + ['potato', 30, 1], + ])('allow setting and validates pollInterval', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ pollInterval: value })); + expect(config.pollInterval).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [false, false, 0], + [true, true, 0], + ['', false, 1], + ['true', true, 1], + [0, false, 1], + [1, true, 1], + ])('allows setting stream and validates offline', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ offline: value })); + expect(config.offline).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [false, false, 0], + [true, true, 0], + ['', false, 1], + ['true', true, 1], + [0, false, 1], + [1, true, 1], + ])('allows setting stream and validates stream', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ stream: value })); + expect(config.stream).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [false, false, 0], + [true, true, 0], + ['', false, 1], + ['true', true, 1], + [0, false, 1], + [1, true, 1], + ])('allows setting stream and validates useLdd', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ useLdd: value })); + expect(config.useLdd).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [false, false, 0], + [true, true, 0], + ['', false, 1], + ['true', true, 1], + [0, false, 1], + [1, true, 1], + ])('allows setting stream and validates sendEvents', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ sendEvents: value })); + expect(config.sendEvents).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [false, false, 0], + [true, true, 0], + ['', false, 1], + ['true', true, 1], + [0, false, 1], + [1, true, 1], + ])('allows setting stream and validates allAttributesPrivate', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ allAttributesPrivate: value })); + expect(config.allAttributesPrivate).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [['a', 'b', 'c'], ['a', 'b', 'c'], 0], + [[], [], 0], + [[0], [], 1], + ['potato', [], 1], + ])('allows setting and validates privateAttributes', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ privateAttributes: value })); + expect(config.privateAttributes).toStrictEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [0, 0, 0], + [500, 500, 0], + ['potato', 1000, 1], + ])('allow setting and validates contextKeysCapacity', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ contextKeysCapacity: value })); + expect(config.contextKeysCapacity).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [0, 0, 0], + [500, 500, 0], + ['potato', 300, 1], + ])('allow setting and validates contextKeysFlushInterval', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ contextKeysFlushInterval: value })); + expect(config.contextKeysFlushInterval).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [false, false, 0], + [true, true, 0], + ['', false, 1], + ['true', true, 1], + [0, false, 1], + [1, true, 1], + ])('allows setting stream and validates diagnosticOptOut', (value, expected, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ diagnosticOptOut: value })); + expect(config.diagnosticOptOut).toEqual(expected); + expect(logger(config).getCount()).toEqual(warnings); + }); + + it.each([ + [0, 60, [ + { level: LogLevel.Warn, matches: /Config option "diagnosticRecordingInterval" had invalid/ }, + ]], + [500, 500, []], + ['potato', 900, [ + { level: LogLevel.Warn, matches: /Config option "diagnosticRecordingInterval" should be of type/ }, + ]], + ])('allow setting and validates diagnosticRecordingInterval', (value, expected, logs) => { + // @ts-ignore + const config = new Configuration(withLogger({ diagnosticRecordingInterval: value })); + expect(config.diagnosticRecordingInterval).toEqual(expected); + logger(config).expectMessages(logs); + }); + + it('discards unrecognized options with a warning', () => { + // @ts-ignore + const config = new Configuration(withLogger({ yes: 'no', cat: 'yes' })); + expect(logger(config).getCount()).toEqual(2); + logger(config).expectMessages([ + { + level: LogLevel.Warn, matches: /Ignoring unknown config option "yes"/, + }, + { + level: LogLevel.Warn, matches: /Ignoring unknown config option "cat"/, + }, + ]); + }); + + // This is more thoroughly tested in the application tags test. + it.each([ + [{ application: { id: 'valid-id', version: 'valid-version' } }, 0], + [{ application: 'tomato' }, 1], + ])('handles application tag settings', (values, warnings) => { + // @ts-ignore + const config = new Configuration(withLogger({ ...values })); + expect(logger(config).getCount()).toEqual(warnings); + }); +}); diff --git a/server-sdk-common/__tests__/options/ServiceEndpoints.test.ts b/server-sdk-common/__tests__/options/ServiceEndpoints.test.ts new file mode 100644 index 0000000000..4877adbd84 --- /dev/null +++ b/server-sdk-common/__tests__/options/ServiceEndpoints.test.ts @@ -0,0 +1,19 @@ +import ServiceEndpoints from '../../src/options/ServiceEndpoints'; + +describe.each([ + [ + { baseUri: 'https://sdk.launchdarkly.com', streamingUri: 'https://stream.launchdarkly.com', eventsUri: 'https://events.launchdarkly.com' }, + { baseUri: 'https://sdk.launchdarkly.com', streamingUri: 'https://stream.launchdarkly.com', eventsUri: 'https://events.launchdarkly.com' }, + ], + [ + { baseUri: 'https://sdk.launchdarkly.com/', streamingUri: 'https://stream.launchdarkly.com/', eventsUri: 'https://events.launchdarkly.com/' }, + { baseUri: 'https://sdk.launchdarkly.com', streamingUri: 'https://stream.launchdarkly.com', eventsUri: 'https://events.launchdarkly.com' }, + ], +])('given endpoint urls', (input, expected) => { + it('has canonical urls', () => { + const endpoints = new ServiceEndpoints(input.streamingUri, input.baseUri, input.eventsUri); + expect(endpoints.streaming).toEqual(expected.streamingUri); + expect(endpoints.polling).toEqual(expected.baseUri); + expect(endpoints.events).toEqual(expected.eventsUri); + }); +}); diff --git a/server-sdk-common/__tests__/options/validators.test.ts b/server-sdk-common/__tests__/options/validators.test.ts new file mode 100644 index 0000000000..3eb73b57ec --- /dev/null +++ b/server-sdk-common/__tests__/options/validators.test.ts @@ -0,0 +1,101 @@ +import TypeValidators, { TypeArray } from '../../src/options/validators'; + +const stringValue = 'this is a string'; +const numberValue = 3.14159; +const objectValue = { yes: 'no' }; +const functionValue = () => ({}); +const stringArrayValue = ['these', 'are', 'strings']; +const booleanValue = true; + +const allValues = [ + stringValue, + numberValue, + objectValue, + functionValue, + stringArrayValue, + booleanValue, +]; + +function without(array: Array, value: any): Array { + const returnArray = [...array]; + const index = returnArray.indexOf(value); + if (index > -1) { + returnArray.splice(index, 1); + } + return returnArray; +} + +const invalidForString = without(allValues, stringValue); +const invalidForNumber = without(allValues, numberValue); +const invalidForObject = without(allValues, objectValue); +const invalidForFactory = without(invalidForObject, functionValue); +const invalidForStringArray = without(allValues, stringArrayValue); +const invalidForBoolean = without(allValues, booleanValue); + +describe.each([ + [TypeValidators.String, [stringValue], invalidForString], + [TypeValidators.Number, [numberValue], invalidForNumber], + [TypeValidators.ObjectOrFactory, [objectValue, functionValue], invalidForFactory], + [TypeValidators.Object, [objectValue], invalidForObject], + [TypeValidators.StringArray, [stringArrayValue], invalidForStringArray], + [TypeValidators.Boolean, [booleanValue], invalidForBoolean], +])( + 'Given a validator, valid values, and invalid values', + (validator, validValues, invalidValues) => { + it(`validates the correct type ${validator.getType()}: ${validValues}`, () => { + validValues.forEach((validValue) => { + expect(validator.is(validValue)).toBeTruthy(); + }); + }); + + it(`does not validate incorrect types ${validator.getType()}: ${invalidValues}`, () => { + invalidValues.forEach((invalidValue) => { + expect(validator.is(invalidValue)).toBeFalsy(); + }); + }); + }, +); + +describe.each([ + [TypeValidators.StringArray, [['a', 'b', 'c', 'd'], []], [[0, 'potato'], [{}]]], + [new TypeArray('number[]', 0), [[0, 1, 2, 3], []], [[0, 'potato'], [{}]]], + [new TypeArray('object[]', {}), [[{}, { yes: 'no' }], []], [[0, 'potato'], [{}, 17]]], +])('given an array validator, valid arrays, and invalid arrays', (validator, validValues, invalidValues) => { + it(`validates the correct type ${validator.getType()}: ${validValues}`, () => { + validValues.forEach((validValue) => { + expect(validator.is(validValue)).toBeTruthy(); + }); + }); + + it(`does not validate incorrect types ${validator.getType()}: ${invalidValues}`, () => { + invalidValues.forEach((invalidValue) => { + expect(validator.is(invalidValue)).toBeFalsy(); + }); + }); +}); + +describe('given a regex validator', () => { + const validator = TypeValidators.StringMatchingRegex(/^(\w|\.|-)+$/); + it('matches valid instances', () => { + expect(validator.is('valid-version._')).toBeTruthy(); + }); + + it('does not match invalid instances', () => { + expect(validator.is('invalid-version!@#$%^&*()')).toBeFalsy(); + }); +}); + +describe.each([ + [TypeValidators.NumberWithMin(0), 0], + [TypeValidators.NumberWithMin(10), 10], + [TypeValidators.NumberWithMin(1000), 1000], +])('given minumum number validators', (validator, min) => { + it('validates numbers equal or above the minimum', () => { + expect(validator.is(min)).toBeTruthy(); + expect(validator.is(min + 1)).toBeTruthy(); + }); + + it('does not validate numbers less than the minimum', () => { + expect(validator.is(min - 1)).toBeFalsy(); + }); +}); diff --git a/server-sdk-common/src/options/ApplicationTags.ts b/server-sdk-common/src/options/ApplicationTags.ts new file mode 100644 index 0000000000..00f9305016 --- /dev/null +++ b/server-sdk-common/src/options/ApplicationTags.ts @@ -0,0 +1,48 @@ +import OptionMessages from './OptionMessages'; +import { ValidatedOptions } from './ValidatedOptions'; +import TypeValidators from './validators'; + +/** +* Expression to validate characters that are allowed in tag keys and values. +*/ +const allowedTagCharacters = /^(\w|\.|-)+$/; + +const tagValidator = TypeValidators.StringMatchingRegex(allowedTagCharacters); + +/** + * Class for managing tags. + * + * @internal + */ +export default class ApplicationTags { + public readonly value?: string; + + constructor(options: ValidatedOptions) { + const tags: Record = {}; + const application = options?.application; + + if (application?.id !== null && application?.id !== undefined) { + if (tagValidator.is(application.id)) { + tags['application-id'] = [application.id]; + } else { + options.logger?.warn(OptionMessages.invalidTagValue('application.id')); + } + } + + if (application?.version !== null && application?.version !== undefined) { + if (tagValidator.is(application.version)) { + tags['application-version'] = [application.version]; + } else { + options.logger?.warn(OptionMessages.invalidTagValue('application.version')); + } + } + + const tagKeys = Object.keys(tags); + if (tagKeys.length) { + this.value = tagKeys + .sort() + .flatMap((key) => tags[key].sort().map((value) => `${key}/${value}`)) + .join(' '); + } + } +} diff --git a/server-sdk-common/src/options/Configuration.ts b/server-sdk-common/src/options/Configuration.ts new file mode 100644 index 0000000000..8fe8fe44f4 --- /dev/null +++ b/server-sdk-common/src/options/Configuration.ts @@ -0,0 +1,234 @@ +import { + LDLogger, LDOptions, LDProxyOptions, LDTLSOptions, +} from '../api'; +import ApplicationTags from './ApplicationTags'; +import OptionMessages from './OptionMessages'; +import ServiceEndpoints from './ServiceEndpoints'; +import { ValidatedOptions } from './ValidatedOptions'; +import TypeValidators, { NumberWithMinimum, TypeValidator } from './validators'; + +// Once things are internal to the implementation of the SDK we can depend on +// types. Calls to the SDK could contain anything without any regard to typing. +// So, data we take from external sources must be normalized into something +// that can be trusted. + +/** + * These perform cursory validations. Complex objects are implemented with classes + * and these should allow for conditional construction. + */ +const validations: Record = { + baseUri: TypeValidators.String, + streamUri: TypeValidators.String, + eventsUri: TypeValidators.String, + timeout: TypeValidators.Number, + capacity: TypeValidators.Number, + logger: TypeValidators.Object, + featureStore: TypeValidators.Object, + bigSegments: TypeValidators.Object, + updateProcessor: TypeValidators.ObjectOrFactory, + flushInterval: TypeValidators.Number, + pollInterval: TypeValidators.NumberWithMin(30), + proxyOptions: TypeValidators.Object, + offline: TypeValidators.Boolean, + stream: TypeValidators.Boolean, + streamInitialReconnectDelay: TypeValidators.Number, + useLdd: TypeValidators.Boolean, + sendEvents: TypeValidators.Boolean, + allAttributesPrivate: TypeValidators.Boolean, + privateAttributes: TypeValidators.StringArray, + contextKeysCapacity: TypeValidators.Number, + contextKeysFlushInterval: TypeValidators.Number, + tlsParams: TypeValidators.Object, + diagnosticOptOut: TypeValidators.Boolean, + diagnosticRecordingInterval: TypeValidators.NumberWithMin(60), + wrapperName: TypeValidators.String, + wrapperVersion: TypeValidators.String, + application: TypeValidators.Object, +}; + +const defaultValues: ValidatedOptions = { + baseUri: 'https://sdk.launchdarkly.com', + streamUri: 'https://stream.launchdarkly.com', + eventsUri: 'https://events.launchdarkly.com', + stream: true, + streamInitialReconnectDelay: 1, + sendEvents: true, + timeout: 5, + capacity: 10000, + flushInterval: 5, + pollInterval: 30, + offline: false, + useLdd: false, + allAttributesPrivate: false, + privateAttributes: [], + contextKeysCapacity: 1000, + contextKeysFlushInterval: 300, + diagnosticOptOut: false, + diagnosticRecordingInterval: 900, + // TODO: Implement once available. + // featureStore: InMemoryFeatureStore(), +}; + +function validateTypesAndNames(options: LDOptions): { + errors: string[], validatedOptions: ValidatedOptions +} { + const errors: string[] = []; + const validatedOptions: ValidatedOptions = { ...defaultValues }; + Object.keys(options).forEach((optionName) => { + // We need to tell typescript it doesn't actually know what options are. + // If we don't then it complains we are doing crazy things with it. + const optionValue = (options as unknown as any)[optionName]; + const validator = validations[optionName]; + if (validator) { + if (!validator.is(optionValue)) { + if (validator.getType() === 'boolean') { + errors.push(OptionMessages.wrongOptionTypeBoolean( + optionName, + typeof optionValue, + )); + validatedOptions[optionName] = !!optionValue; + } else if (validator instanceof NumberWithMinimum + && TypeValidators.Number.is(optionValue)) { + const { min } = validator as NumberWithMinimum; + errors.push(OptionMessages.optionBelowMinimum(optionName, optionValue, min)); + validatedOptions[optionName] = min; + } else { + errors.push(OptionMessages.wrongOptionType( + optionName, + validator.getType(), + typeof optionValue, + )); + validatedOptions[optionName] = defaultValues[optionName]; + } + } else { + validatedOptions[optionName] = optionValue; + } + } else { + options.logger?.warn(OptionMessages.unknownOption(optionName)); + } + }); + return { errors, validatedOptions }; +} + +function validateEndpoints(options: LDOptions, validatedOptions: ValidatedOptions) { + const { baseUri, streamUri, eventsUri } = options; + const streamingEndpointSpecified = streamUri !== undefined && streamUri !== null; + const pollingEndpointSpecified = baseUri !== undefined && baseUri !== null; + const eventEndpointSpecified = eventsUri !== undefined && eventsUri !== null; + + if ((streamingEndpointSpecified === pollingEndpointSpecified) + && (streamingEndpointSpecified === eventEndpointSpecified)) { + // Either everything is default, or everything is set. + return; + } + + if (!streamingEndpointSpecified && validatedOptions.stream) { + validatedOptions.logger?.warn(OptionMessages.partialEndpoint('streamUri')); + } + + if (!pollingEndpointSpecified) { + validatedOptions.logger?.warn(OptionMessages.partialEndpoint('baseUri')); + } + + if (!eventEndpointSpecified && validatedOptions.sendEvents) { + validatedOptions.logger?.warn(OptionMessages.partialEndpoint('eventsUri')); + } +} + +/** + * Configuration options for the LDClient. + * + * @internal + */ +export default class Configuration { + public readonly serviceEndpoints: ServiceEndpoints; + + public readonly eventsCapacity: number; + + public readonly timeout: number; + + public readonly logger?: LDLogger; + + public readonly flushInterval: number; + + public readonly pollInterval: number; + + public readonly proxyOptions?: LDProxyOptions; + + public readonly offline: boolean; + + public readonly stream: boolean; + + public readonly streamInitialReconnectDelay: number; + + public readonly useLdd: boolean; + + public readonly sendEvents: boolean; + + public readonly allAttributesPrivate: boolean; + + // TODO: Change to attribute references once available. + public readonly privateAttributes: string[]; + + public readonly contextKeysCapacity: number; + + public readonly contextKeysFlushInterval: number; + + public readonly tlsParams?: LDTLSOptions; + + public readonly diagnosticOptOut: boolean; + + public readonly wrapperName?: string; + + public readonly wrapperVersion?: string; + + public readonly tags: ApplicationTags; + + public readonly diagnosticRecordingInterval: number; + + constructor(options: LDOptions = {}) { + // The default will handle undefined, but not null. + // Because we can be called from JS we need to be extra defensive. + // eslint-disable-next-line no-param-reassign + options = options || {}; + // If there isn't a valid logger from the platform, then logs would go nowhere. + this.logger = options.logger; + + const { errors, validatedOptions } = validateTypesAndNames(options); + errors.forEach((error) => { + this.logger?.warn(error); + }); + + validateEndpoints(options, validatedOptions); + + this.serviceEndpoints = new ServiceEndpoints( + validatedOptions.streamUri, + validatedOptions.baseUri, + validatedOptions.eventsUri, + ); + this.eventsCapacity = validatedOptions.capacity; + this.timeout = validatedOptions.timeout; + // TODO: featureStore + // TODO: bigSegments + // TODO: updateProcessor + this.flushInterval = validatedOptions.flushInterval; + this.pollInterval = validatedOptions.pollInterval; + this.proxyOptions = validatedOptions.proxyOptions; + + this.offline = validatedOptions.offline; + this.stream = validatedOptions.stream; + this.streamInitialReconnectDelay = validatedOptions.streamInitialReconnectDelay; + this.useLdd = validatedOptions.useLdd; + this.sendEvents = validatedOptions.sendEvents; + this.allAttributesPrivate = validatedOptions.allAttributesPrivate; + this.privateAttributes = validatedOptions.privateAttributes; + this.contextKeysCapacity = validatedOptions.contextKeysCapacity; + this.contextKeysFlushInterval = validatedOptions.contextKeysFlushInterval; + this.tlsParams = validatedOptions.tlsParams; + this.diagnosticOptOut = validatedOptions.diagnosticOptOut; + this.wrapperName = validatedOptions.wrapperName; + this.wrapperVersion = validatedOptions.wrapperVersion; + this.tags = new ApplicationTags(validatedOptions); + this.diagnosticRecordingInterval = validatedOptions.diagnosticRecordingInterval; + } +} diff --git a/server-sdk-common/src/options/OptionMessages.ts b/server-sdk-common/src/options/OptionMessages.ts new file mode 100644 index 0000000000..129afce7ba --- /dev/null +++ b/server-sdk-common/src/options/OptionMessages.ts @@ -0,0 +1,30 @@ +/** + * Messages for issues which can be encountered from processing the configuration options. + * + * @internal + */ +export default class OptionMessages { + static deprecated(oldName: string, newName: string): string { return `"${oldName}" is deprecated, please use "${newName}"`; } + + static optionBelowMinimum(name: string, value: number, min: number): string { + return `Config option "${name}" had invalid value of ${value}, using minimum of ${min} instead`; + } + + static unknownOption(name: string): string { return `Ignoring unknown config option "${name}"`; } + + static wrongOptionType(name: string, expectedType: string, actualType: string): string { + return `Config option "${name}" should be of type ${expectedType}, got ${actualType}, using default value`; + } + + static wrongOptionTypeBoolean(name: string, actualType: string): string { + return `Config option "${name}" should be a boolean, got ${actualType}, converting to boolean`; + } + + static invalidTagValue(name: string): string { + return `Config option "${name}" must only contain letters, numbers, ., _ or -.`; + } + + static partialEndpoint(name: string): string { + return `You have set custom uris without specifying the ${name} URI; connections may not work properly`; + } +} diff --git a/server-sdk-common/src/options/ServiceEndpoints.ts b/server-sdk-common/src/options/ServiceEndpoints.ts new file mode 100644 index 0000000000..567df6c81c --- /dev/null +++ b/server-sdk-common/src/options/ServiceEndpoints.ts @@ -0,0 +1,22 @@ +function canonicalizeUri(uri: string): string { + return uri.replace(/\/+$/, ''); +} + +/** + * Specifies the base service URIs used by SDK components. + * + * @internal + */ +export default class ServiceEndpoints { + public readonly streaming: string; + + public readonly polling: string; + + public readonly events: string; + + public constructor(streaming: string, polling: string, events: string) { + this.streaming = canonicalizeUri(streaming); + this.polling = canonicalizeUri(polling); + this.events = canonicalizeUri(events); + } +} diff --git a/server-sdk-common/src/options/ValidatedOptions.ts b/server-sdk-common/src/options/ValidatedOptions.ts new file mode 100644 index 0000000000..d953657d30 --- /dev/null +++ b/server-sdk-common/src/options/ValidatedOptions.ts @@ -0,0 +1,36 @@ +import { LDLogger, LDProxyOptions, LDTLSOptions } from '../api'; + +/** + * This interface applies to the options after they have been validated and defaults + * have been applied. + * + * @internal + */ +export interface ValidatedOptions { + baseUri: string; + streamUri: string; + eventsUri: string; + stream: boolean; + streamInitialReconnectDelay: number; + sendEvents: boolean; + timeout: number; + capacity: number; + flushInterval: number; + pollInterval: number; + offline: boolean; + useLdd: boolean; + allAttributesPrivate: false; + privateAttributes: string[]; + contextKeysCapacity: number; + contextKeysFlushInterval: number; + diagnosticOptOut: boolean; + diagnosticRecordingInterval: number; + tlsParams?: LDTLSOptions; + wrapperName?: string; + wrapperVersion?: string; + application?: { id?: string; version?: string; }; + proxyOptions?: LDProxyOptions; + logger?: LDLogger; + // Allow indexing this by a string for the validation step. + [index: string]: any; +} diff --git a/server-sdk-common/src/options/validators.ts b/server-sdk-common/src/options/validators.ts new file mode 100644 index 0000000000..64c3ca5999 --- /dev/null +++ b/server-sdk-common/src/options/validators.ts @@ -0,0 +1,154 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable max-classes-per-file */ + +// The classes here are static, but needs to be instantiated to +// support the generic functionality. Which is why we do not care about using +// `this` + +// These validators are also of trivial complexity, so we are allowing more than +// one per file. + +/** + * Interface for type validation. + * + * @internal + */ +export interface TypeValidator { + is(u:unknown): boolean; + getType(): string; +} + +/** + * Validate a factory or instance. + * + * @internal + */ +export class FactoryOrInstance implements TypeValidator { + is(factoryOrInstance: unknown) { + if (Array.isArray(factoryOrInstance)) { + return false; + } + const anyFactory = factoryOrInstance as any; + const typeOfFactory = typeof anyFactory; + return typeOfFactory === 'function' || typeOfFactory === 'object'; + } + + getType(): string { + return 'factory method or object'; + } +} + +/** + * Validate a basic type. + * + * @internal + */ +export class Type implements TypeValidator { + private typeName: string; + + protected typeOf: string; + + constructor(typeName: string, example: T) { + this.typeName = typeName; + this.typeOf = typeof example; + } + + is(u: unknown): u is T { + if (Array.isArray(u)) { + return false; + } + return typeof u === this.typeOf; + } + + getType(): string { + return this.typeName; + } +} + +export class TypeArray implements TypeValidator { + private typeName: string; + + protected typeOf: string; + + constructor(typeName: string, example: T) { + this.typeName = typeName; + this.typeOf = typeof example; + } + + is(u: unknown): u is T { + if (Array.isArray(u)) { + if (u.length > 0) { + return u.every((val) => typeof val === this.typeOf); + } + return true; + } + return false; + } + + getType(): string { + return this.typeName; + } +} + +/** + * Validate a value is a number and is greater or eval than a minimum. + * + * @internal + */ +export class NumberWithMinimum extends Type { + readonly min: number; + + constructor(min: number) { + super(`number with minimum value of ${min}`, 0); + this.min = min; + } + + override is(u: unknown): u is number { + return typeof u === this.typeOf && (u as number) >= this.min; + } +} + +/** + * Validate a value is a string and it matches the given expression. + * + * @internal + */ +export class StringMatchingRegex extends Type { + readonly expression: RegExp; + + constructor(expression: RegExp) { + super(`string matching ${expression}`, ''); + this.expression = expression; + } + + override is(u: unknown): u is string { + return !!(u as string).match(this.expression); + } +} + +/** + * A set of standard type validators. + * + * @internal + */ +export default class TypeValidators { + static readonly String = new Type('string', ''); + + static readonly Number = new Type('number', 0); + + static readonly ObjectOrFactory = new FactoryOrInstance(); + + static readonly Object = new Type('object', {}); + + static readonly StringArray = new TypeArray('string[]', ''); + + static readonly Boolean = new Type('boolean', true); + + static NumberWithMin(min: number): NumberWithMinimum { + return new NumberWithMinimum(min); + } + + static StringMatchingRegex(expression: RegExp): StringMatchingRegex { + return new StringMatchingRegex(expression); + } +} diff --git a/server-sdk-common/tsconfig.json b/server-sdk-common/tsconfig.json index 0c10db0c9d..4b913cbe13 100644 --- a/server-sdk-common/tsconfig.json +++ b/server-sdk-common/tsconfig.json @@ -14,5 +14,6 @@ "sourceMap": true, "declaration": true, "declarationMap": true, // enables importers to jump to source + "stripInternal": true } }