packages/core-api: added initial ConfigReader

This commit is contained in:
Patrik Oldsberg
2020-05-26 20:46:31 +02:00
parent ce22236c3c
commit 0c6dbe0545
3 changed files with 307 additions and 0 deletions
@@ -0,0 +1,126 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
import { ConfigReader } from './ConfigReader';
const DATA = {
zero: 0,
one: 1,
true: true,
false: false,
null: null,
string: 'string',
emptyString: '',
strings: ['string1', 'string2'],
badStrings: ['string1', ''],
worseStrings: ['string1', 3] as string[],
worstStrings: ['string1', 'string2', {}] as string[],
nested: {
one: 1,
string: 'string',
strings: ['string1', 'string2'],
},
nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[],
};
describe('ConfigReader', () => {
it('should read empty config with valid keys', () => {
const config = new ConfigReader({});
expect(config.getString('x')).toBeUndefined();
expect(config.getString('x_x')).toBeUndefined();
expect(config.getString('x-X')).toBeUndefined();
expect(config.getString('x0')).toBeUndefined();
expect(config.getString('X-x2')).toBeUndefined();
expect(config.getString('x0_x0')).toBeUndefined();
expect(config.getString('x_x-x_x')).toBeUndefined();
});
it('should throw on invalid keys', () => {
const config = new ConfigReader({});
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('0')).toThrow(/^Invalid config key/);
expect(() => config.getString('(')).toThrow(/^Invalid config key/);
expect(() => config.getString('z-_')).toThrow(/^Invalid config key/);
expect(() => config.getString('-')).toThrow(/^Invalid config key/);
expect(() => config.getString('.a')).toThrow(/^Invalid config key/);
expect(() => config.getString('0.a')).toThrow(/^Invalid config key/);
expect(() => config.getString('0a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.0a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a..a')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a...')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a._')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/);
});
it('should read valid values', () => {
const config = new ConfigReader(DATA);
expect(config.getNumber('zero')).toBe(0);
expect(config.getNumber('one')).toBe(1);
expect(config.getBoolean('true')).toBe(true);
expect(config.getBoolean('false')).toBe(false);
expect(config.getString('string')).toBe('string');
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
expect(config.getConfig('nested').getNumber('one')).toBe(1);
expect(config.getConfig('nested').getString('string')).toBe('string');
expect(config.getConfig('nested').getStringArray('strings')).toEqual([
'string1',
'string2',
]);
const [config1, config2, config3] = config.getConfigArray('nestlings');
expect(config1.getBoolean('boolean')).toBe(true);
expect(config2.getString('string')).toBe('string');
expect(config3.getNumber('number')).toBe(42);
});
it('should fail to read invalid values', () => {
const config = new ConfigReader(DATA);
expect(() => config.getNumber('string')).toThrow(
'Invalid type in config for key string, got string, wanted number',
);
expect(() => config.getString('one')).toThrow(
'Invalid type in config for key one, got number, wanted string',
);
expect(() => config.getNumber('true')).toThrow(
'Invalid type in config for key true, got boolean, wanted number',
);
expect(() => config.getStringArray('null')).toThrow(
'Invalid type in config for key null, got null, wanted string-array',
);
expect(() => config.getString('emptyString')).toThrow(
'Invalid type in config for key emptyString, got empty-string, wanted string',
);
expect(() => config.getStringArray('badStrings')).toThrow(
'Invalid type in config for key badStrings[1], got empty-string, wanted string',
);
expect(() => config.getStringArray('worseStrings')).toThrow(
'Invalid type in config for key worseStrings[1], got number, wanted string',
);
expect(() => config.getStringArray('worstStrings')).toThrow(
'Invalid type in config for key worstStrings[2], got object, wanted string',
);
expect(() => config.getConfig('one')).toThrow(
'Invalid type in config for key one, got number, wanted object',
);
expect(() => config.getConfigArray('one')).toThrow(
'Invalid type in config for key one, got number, wanted object-array',
);
});
});
@@ -0,0 +1,164 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
import { ConfigApi, Config } from '../../definitions/ConfigApi';
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
type JsonObject = { [key in string]: JsonValue };
type JsonArray = JsonValue[];
type JsonValue = JsonObject | JsonArray | number | string | boolean | null;
function isObject(value: JsonValue | undefined): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function typeOf(value: JsonValue | undefined): string {
if (value === null) {
return 'null';
} else if (Array.isArray(value)) {
return 'array';
}
const type = typeof value;
if (type === 'number' && isNaN(value as number)) {
return 'nan';
}
return type;
}
function typeErrorMessage(key: string, got: string, wanted: string) {
return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`;
}
function validateString(
key: string,
value: JsonValue | undefined,
): value is string {
if (typeof value === 'string' && value.length > 0) {
return true;
}
if (value === '') {
throw new TypeError(typeErrorMessage(key, 'empty-string', 'string'));
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'string'));
}
return false;
}
export class ConfigReader implements ConfigApi {
static nullReader = new ConfigReader({});
constructor(private readonly data: JsonObject) {}
getConfig(key: string): Config {
const value = this.readValue(key);
if (isObject(value)) {
return new ConfigReader(value);
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'object'));
}
return ConfigReader.nullReader;
}
getConfigArray(key: string): Config[] {
const values = this.readValue(key);
if (Array.isArray(values)) {
return values.map((value, index) => {
if (isObject(value)) {
return new ConfigReader(value);
}
throw new TypeError(
typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'),
);
});
}
if (values !== undefined) {
throw new TypeError(
typeErrorMessage(key, typeOf(values), 'object-array'),
);
}
return [];
}
getNumber(key: string): number | undefined {
const value = this.readValue(key);
if (typeof value === 'number' && !isNaN(value)) {
return value;
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'number'));
}
return undefined;
}
getBoolean(key: string): boolean | undefined {
const value = this.readValue(key);
if (typeof value === 'boolean') {
return value;
}
if (value !== undefined) {
throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean'));
}
return undefined;
}
getString(key: string): string | undefined {
const value = this.readValue(key);
if (validateString(key, value)) {
return value;
}
return undefined;
}
getStringArray(key: string): string[] | undefined {
const values = this.readValue(key);
if (Array.isArray(values)) {
for (const [index, value] of values.entries()) {
const iKey = `${key}[${index}]`;
if (!validateString(iKey, value)) {
throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string'));
}
}
return values as string[];
}
if (values !== undefined) {
throw new TypeError(
typeErrorMessage(key, typeOf(values), 'string-array'),
);
}
return undefined;
}
private readValue(key: string): JsonValue | undefined {
const parts = key.split('.');
let value: JsonValue | undefined = this.data;
for (const part of parts) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid config key '${key}'`);
}
if (isObject(value)) {
value = value[part];
} else {
value = undefined;
}
}
return value;
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { ConfigReader } from './ConfigReader';