Commit 094de8f9 authored by mohoff's avatar mohoff Committed by Richard Crosby

feat: store and validate parameters object that configures strong-config internally

parent f442119f
lib
# Default output for generated types when strongConfig.load() is called
strong-config.d.ts
# Logs
logs
......
......@@ -23,11 +23,11 @@
"watch": "tsc --watch",
"clean": "rimraf build/",
"build": "tsc",
"dev:load:commonjs": "yarn build; echo 'Loaded Config:\n'; cross-env RUNTIME_ENVIRONMENT=development time node -p -e 'const StrongConfig = require(\".\"); JSON.stringify((new StrongConfig()).load(\"./example\"))' | jq .",
"dev:load:es6": "echo 'Loaded Config:\n'; cross-env RUNTIME_ENVIRONMENT=development time ts-node -T -p -e 'import StrongConfig from \".\"; JSON.stringify((new StrongConfig()).load(\"example\"))' | jq .",
"dev:load:commonjs": "yarn build; echo 'Loaded Config:\n'; cross-env RUNTIME_ENVIRONMENT=development time node -p -e 'const StrongConfig = require(\"./lib\"); JSON.stringify((new StrongConfig({ configPath: \"example/\", schemaPath:\"example/schema.json\" })).load())' | jq .",
"dev:load:es6": "echo 'Loaded Config:\n'; cross-env RUNTIME_ENVIRONMENT=development time ts-node -T -p -e 'import StrongConfig from \"./src\"; JSON.stringify((new StrongConfig({ configPath: \"example/\", schemaPath:\"example/schema.json\" })).load())' | jq .",
"dev:load": "yarn dev:load:es6",
"dev:load:watch": "nodemon --exec 'yarn dev:load:es6'",
"dev:validate": "echo 'Validation Result:\n'; cross-env RUNTIME_ENVIRONMENT=development time ts-node -T -p -e 'import StrongConfig from \".\"; (new StrongConfig()).validate(\"example/schema.json\", \"example\")'",
"dev:validate": "echo 'Validation Result:\n'; cross-env RUNTIME_ENVIRONMENT=development time ts-node -T -p -e 'import StrongConfig from \"./src\"; (new StrongConfig({ configPath: \"example/\", schemaPath:\"example/schema.json\" })).validate()'",
"dev:validate:watch": "nodemon --exec 'yarn dev:validate'"
},
"devDependencies": {
......
jest.mock('./load')
jest.mock('./validate')
jest.mock('./params/validate')
import { load } from './load'
import { validate } from './validate'
import { validate as validateParams } from './params/validate'
import { defaultParameters } from './params'
const mockedLoad = load as jest.MockedFunction<typeof load>
const mockedValidate = validate as jest.MockedFunction<typeof validate>
const mockedValidateParams = validateParams as jest.MockedFunction<
typeof validateParams
>
const mockedConfig = { some: 'config', runtimeEnvironment: 'development' }
const mockedParameters = defaultParameters
const mockedConfigPath = 'some/config/path'
const mockedValidationResult = true
mockedLoad.mockReturnValue(mockedConfig)
mockedValidate.mockReturnValue(mockedValidationResult)
mockedValidateParams.mockReturnValue(mockedParameters)
import StrongConfig from './index'
import StrongConfig from '.'
describe('StrongConfig class', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('can be instantiated', () => {
expect(new StrongConfig()).toBeDefined()
})
describe('instantiation', () => {
it('can be instantiated without constructor arguments', () => {
expect(new StrongConfig()).toBeDefined()
})
it('can be instantiated with a parameters object', () => {
expect(new StrongConfig(mockedParameters)).toBeDefined()
})
it('validates the parameters object', () => {
new StrongConfig(mockedParameters)
expect(mockedValidateParams).toHaveBeenCalledWith(mockedParameters)
})
it('accepts and stores a parameter object', () => {
const mockedParams = {}
const strongConfig = new StrongConfig(mockedParams)
it('stores the validated parameters object', () => {
const strongConfig = new StrongConfig(mockedParameters)
expect(strongConfig.params).toStrictEqual(mockedParams)
expect(strongConfig.parameters).toStrictEqual(mockedParameters)
})
})
it('strongConfig instance exposes "load()"', () => {
......@@ -48,6 +68,14 @@ describe('StrongConfig class', () => {
expect(result).toStrictEqual(mockedConfig)
})
it('calls imported load() with initialized parameters', () => {
const strongConfig = new StrongConfig(mockedParameters)
strongConfig.load()
expect(mockedLoad).toHaveBeenCalledWith(mockedParameters)
})
it('memoizes previously loaded config', () => {
const strongConfig = new StrongConfig()
......@@ -64,13 +92,33 @@ describe('StrongConfig class', () => {
it('calls imported validate() and returns its result', () => {
const strongConfig = new StrongConfig()
const result = strongConfig.validate(
'path/to/schema.json',
'some/config/path'
)
const result = strongConfig.validate(mockedConfigPath)
expect(mockedValidate).toHaveBeenCalledTimes(1)
expect(result).toStrictEqual(mockedValidationResult)
})
it('validates config paths that are passed', () => {
const strongConfig = new StrongConfig(mockedParameters)
strongConfig.validate(mockedConfigPath, mockedConfigPath)
expect(mockedValidate).toHaveBeenCalledWith(
[mockedConfigPath, mockedConfigPath],
mockedParameters
)
})
it('validates parameters.configPath by default', () => {
const strongConfig = new StrongConfig(mockedParameters)
// Do not pass any configPaths to trigger default validation
strongConfig.validate()
expect(mockedValidate).toHaveBeenCalledWith(
[defaultParameters.configPath],
mockedParameters
)
})
})
})
import R from 'ramda'
import { load } from './load'
import { validate } from './validate'
import { validate as validateParameters } from './params/validate'
import { MemoizedConfig } from './types'
interface Parameters {
// TODO: define Parameters interface
}
import { defaultParameters, Parameters } from './params'
export = class StrongConfig {
public readonly params: Parameters | undefined
public readonly parameters: Parameters
private config: MemoizedConfig
constructor(params?: Parameters) {
// TODO: validate params
this.params = params
constructor(parameters?: Partial<Parameters>) {
this.parameters = validateParameters(
parameters
? {
...defaultParameters,
...parameters,
}
: defaultParameters
)
}
public load(configDir?: string): ReturnType<typeof load> {
public load(): ReturnType<typeof load> {
if (this.config) {
return this.config
}
this.config = load(configDir)
this.config = load(this.parameters)
return this.config
}
public validate(
schemaPath: string,
...configPaths: string[]
): ReturnType<typeof validate> {
return validate(schemaPath, ...configPaths)
public validate(...configPaths: string[]): ReturnType<typeof validate> {
return validate(
R.isEmpty(configPaths) ? [this.parameters.configPath] : configPaths,
this.parameters
)
}
}
jest.mock('./utils/generate-type-from-schema')
jest.mock('./utils/hydrate-config')
jest.mock('./utils/generate-type-from-schema')
jest.mock('./utils/validate-config')
jest.mock('./utils/validate-json')
jest.mock('./utils/read-file')
jest.mock('./utils/sops')
import { defaultParameters } from './params'
import { generateTypeFromSchema } from './utils/generate-type-from-schema'
import { hydrateConfig, InnerHydrateFunction } from './utils/hydrate-config'
import { validateConfig } from './utils/validate-config'
import { validateJson } from './utils/validate-json'
import { readConfigFile, readSchemaFile } from './utils/read-file'
import { decryptToObject } from './utils/sops'
import { HydratedConfig } from './types'
const mockedParameters = defaultParameters
// Note: This variable must be named according to defaultParameters.runtimeEnvName
const RUNTIME_ENVIRONMENT = 'development'
const mockedConfigFile = {
filePath: './config/development.yaml',
contents: {
......@@ -27,7 +31,6 @@ const mockedSchemaFile = {
contents: { key: 'schema' },
filePath: './config/schema.json',
}
const RUNTIME_ENVIRONMENT = 'development'
const mockedHydratedConfig: HydratedConfig = {
some: 'config',
runtimeEnvironment: RUNTIME_ENVIRONMENT,
......@@ -43,15 +46,15 @@ const mockedDecryptToObject = decryptToObject as jest.MockedFunction<
typeof decryptToObject
>
const mockedHydrateConfig = hydrateConfig as jest.MockedFunction<
typeof hydrateConfig
typeof hydrateConfig & jest.Mock
>
mockedReadConfigFile.mockReturnValue(mockedConfigFile)
mockedReadSchemaFile.mockReturnValue(mockedSchemaFile)
mockedDecryptToObject.mockReturnValue(mockedDecryptedConfigFile)
const innerHydrateFunction: jest.MockedFunction<InnerHydrateFunction> = jest.fn(
() => mockedHydratedConfig
)
const innerHydrateFunction = jest
.fn()
.mockReturnValue(mockedHydratedConfig) as jest.Mock<InnerHydrateFunction>
mockedHydrateConfig.mockReturnValue(innerHydrateFunction)
import { load } from './load'
......@@ -62,7 +65,9 @@ describe('load()', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.resetModules()
process.env = Object.assign(process.env, { RUNTIME_ENVIRONMENT })
process.env = Object.assign(process.env, {
[defaultParameters.runtimeEnvName]: RUNTIME_ENVIRONMENT,
})
})
afterAll(() => {
......@@ -70,17 +75,17 @@ describe('load()', () => {
})
it('throws if RUNTIME_ENVIRONMENT is not set', () => {
delete process.env.RUNTIME_ENVIRONMENT
delete process.env[defaultParameters.runtimeEnvName]
expect(() => load()).toThrow(
/process.env.RUNTIME_ENVIRONMENT must be defined/
)
expect(() => load(mockedParameters)).toThrow('runtimeEnv must be defined')
process.env = Object.assign(process.env, { RUNTIME_ENVIRONMENT })
process.env = Object.assign(process.env, {
[defaultParameters.runtimeEnvName]: RUNTIME_ENVIRONMENT,
})
})
it('reads the config based on process.env.RUNTIME_ENVIRONMENT', () => {
load()
load(mockedParameters)
expect(mockedReadConfigFile).toHaveBeenCalledWith(
expect.any(String),
......@@ -89,7 +94,7 @@ describe('load()', () => {
})
it('decrypts the config with SOPS', () => {
load()
load(mockedParameters)
expect(mockedDecryptToObject).toHaveBeenCalledWith(
mockedConfigFile.filePath,
......@@ -98,51 +103,56 @@ describe('load()', () => {
})
it('hydrates the config', () => {
load()
load(mockedParameters)
expect(mockedHydrateConfig).toHaveBeenCalledWith(RUNTIME_ENVIRONMENT)
expect(mockedHydrateConfig).toHaveBeenCalledWith(
RUNTIME_ENVIRONMENT,
mockedParameters
)
expect(innerHydrateFunction).toHaveBeenCalledWith(mockedDecryptedConfigFile)
})
it('reads the schema file', () => {
load()
load(mockedParameters)
expect(mockedReadSchemaFile).toHaveBeenCalledWith('schema')
expect(mockedReadSchemaFile).toHaveBeenCalledWith(
defaultParameters.schemaPath
)
})
it('validates config against schema if schema was found', () => {
load()
load(mockedParameters)
expect(validateConfig).toHaveBeenCalledWith(
expect(validateJson).toHaveBeenCalledWith(
mockedHydratedConfig,
mockedSchemaFile.contents
)
})
it('generates types based on schema if schema was found', () => {
load()
load(mockedParameters)
expect(generateTypeFromSchema).toHaveBeenCalledWith('config/schema.json')
expect(generateTypeFromSchema).toHaveBeenCalledWith(mockedParameters)
})
it('skips validating config if schema was not found', () => {
mockedReadSchemaFile.mockReturnValueOnce(undefined)
load()
load(mockedParameters)
expect(validateConfig).toHaveBeenCalledTimes(0)
expect(validateJson).toHaveBeenCalledTimes(0)
})
it('skips generating types if schema was not found', () => {
mockedReadSchemaFile.mockReturnValueOnce(undefined)
load()
load(mockedParameters)
expect(generateTypeFromSchema).toHaveBeenCalledTimes(0)
})
it('returns the config', () => {
const loadedConfig = load()
const loadedConfig = load(mockedParameters)
expect(loadedConfig).toStrictEqual(mockedHydratedConfig)
})
......
......@@ -3,37 +3,37 @@ import path from 'path'
import { generateTypeFromSchema } from './utils/generate-type-from-schema'
import { hydrateConfig } from './utils/hydrate-config'
import { validateConfig } from './utils/validate-config'
import { validateJson } from './utils/validate-json'
import { readConfigFile, readSchemaFile } from './utils/read-file'
import * as sops from './utils/sops'
import { HydratedConfig } from './types'
import { Parameters } from './params'
export const load = (configDir = './config'): HydratedConfig => {
const normalizedConfigDir = path.normalize(configDir)
export const load = (parameters: Parameters): HydratedConfig => {
const normalizedConfigPath = path.normalize(parameters.configPath)
const normalizedSchemaPath = path.normalize(parameters.schemaPath)
const runtimeEnv = process.env[parameters.runtimeEnvName]
if (R.isNil(process.env.RUNTIME_ENVIRONMENT)) {
throw new Error('process.env.RUNTIME_ENVIRONMENT must be defined.')
if (R.isNil(runtimeEnv)) {
throw new Error('runtimeEnv must be defined.')
}
const configFile = readConfigFile(
normalizedConfigDir,
process.env.RUNTIME_ENVIRONMENT
)
const configFile = readConfigFile(normalizedConfigPath, runtimeEnv)
const decrypted = sops.decryptToObject(
configFile.filePath,
configFile.contents
)
const config = hydrateConfig(process.env.RUNTIME_ENVIRONMENT)(decrypted)
const config = hydrateConfig(runtimeEnv, parameters)(decrypted)
const schemaFile = readSchemaFile('schema')
const schemaFile = readSchemaFile(normalizedSchemaPath)
if (schemaFile !== undefined) {
validateConfig(config, schemaFile.contents)
validateJson(config, schemaFile.contents)
generateTypeFromSchema(`${normalizedConfigDir}/schema.json`)
generateTypeFromSchema(parameters)
}
return config
......
export interface TypesParameters {
rootTypeName: string
filePath: string
}
export interface Parameters {
runtimeEnvName: string
types: TypesParameters | false
substitutionPattern: string
configPath: string
schemaPath: string
}
export const defaultParameters: Parameters = {
runtimeEnvName: 'RUNTIME_ENVIRONMENT',
types: {
rootTypeName: 'Config',
filePath: 'strong-config.d.ts',
},
substitutionPattern: '\\$\\{(\\w+)\\}',
configPath: 'config/',
schemaPath: 'config/schema.json',
}
export const parametersSchema = {
type: 'object',
title: 'Schema for strong-config parameters',
required: [
'runtimeEnvName',
'types',
'substitutionPattern',
'configPath',
'schemaPath',
],
additionalProperties: false,
properties: {
runtimeEnvName: {
title: 'The name of the runtime environment variable',
description:
'The value of this variable determines which config is loaded',
examples: ['RUNTIME_ENVIRONMENT', 'RUNTIME_ENV'],
type: 'string',
pattern: '^[a-zA-Z]\\w*$',
},
types: {
title: 'Parameters related to types',
description:
'Type-related parameters controlling the generation of Typescript types for the config',
type: ['object'],
additionalProperties: false,
properties: {
rootTypeName: {
title: 'The name of the generated root type',
description: 'The name of the generated root type',
examples: ['Config', 'AppConfig'],
type: 'string',
pattern: '^[A-Z]\\w*$',
},
filePath: {
title: 'The path of the generate type file',
description: 'The file that the generated types should be stored to',
examples: ['strong-config.d.ts', './types/config.ts'],
type: 'string',
},
},
},
substitutionPattern: {
title: 'The substitution pattern used for replacing template strings',
description:
'The escaped regexp that is used to match against template strings to be replaced with their corresponding environment variable values',
examples: ['\\$\\{(\\w+)\\}', '\\$(\\w+)'],
type: 'string',
format: 'regex',
},
configPath: {
title: 'The path to the config directory',
description: 'A path to a directory that contains all config files',
examples: ['config', '../config', '/app/config/'],
type: 'string',
},
schemaPath: {
title: 'The path to the schema file',
description:
'A path to a file that contains schema definitions for the configs',
examples: [
'config/schema.json',
'../schema.json',
'/app/config/schema.json',
],
type: 'string',
},
},
}
jest.mock('../utils/validate-json')
import { validateJson } from '../utils/validate-json'
import { defaultParameters } from '.'
const mockedValidateJson = validateJson as jest.MockedFunction<
typeof validateJson
>
const mockedParameters = { ...defaultParameters }
mockedValidateJson.mockReturnValue(true)
import { validate } from './validate'
describe('validate()', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('validates json', () => {
validate(mockedParameters)
expect(mockedValidateJson).toHaveBeenCalledWith(
mockedParameters,
expect.any(Object)
)
})
it('returns the params when they are valid', () => {
const validationResult = validate(mockedParameters)
expect(validationResult).toStrictEqual(mockedParameters)
})
it('throws when parameter validation fails', () => {
mockedValidateJson.mockImplementation(() => {
throw new Error('some validation error')
})
expect(() => validate(mockedParameters)).toThrowError(
'some validation error'
)
})
})
import { validateJson } from '../utils/validate-json'
import { parametersSchema } from './schema'
import { JSONObject } from '../types'
import { Parameters } from '.'
export const validate = (parameters: Parameters): Parameters =>
validateJson((parameters as unknown) as JSONObject, parametersSchema) &&
parameters
......@@ -11,6 +11,8 @@ export const isSchema = (filePath: string): boolean =>
R.split('/')
)(filePath)
export const isJson = (filePath: string): boolean => filePath.endsWith('.json')
export const findFiles = (
basePath: string,
globFileName = '**/*',
......
const mockFilePath = './config/schema.json'
const mockGeneratedTypesPath = 'strong-config.d.ts'
import { defaultParameters, TypesParameters } from '../params'
const mockedParameters = defaultParameters
const mockedCompiledTypes = `
export interface TheTopLevelInterface {
name: string;
otherField: number;
}
`
const mockedRootType = `export interface Config extends TheTopLevelInterface {
runtimeEnvironment: string;
const expectedRootType = `export interface Config extends TheTopLevelInterface {
${mockedParameters.runtimeEnvName}: string;
}
`
const mockedSchemaString = `
......@@ -62,25 +63,39 @@ describe('generateTypeFromSchema()', () => {
jest.clearAllMocks()
})
it('immediately returns when types=false', async () => {
await generateTypeFromSchema({
...mockedParameters,
types: false,
})
expect(mockedCompileFromFile).toHaveBeenCalledTimes(0)
})
it('calls compileFromFile with a file path', async () => {
await generateTypeFromSchema(mockFilePath)
await generateTypeFromSchema(mockedParameters)
expect(mockedCompileFromFile).toHaveBeenCalledWith(mockFilePath)
expect(mockedCompileFromFile).toHaveBeenCalledWith(
mockedParameters.schemaPath
)
})
it('reads the file at filePath', async () => {
await generateTypeFromSchema(mockFilePath)
await generateTypeFromSchema(mockedParameters)
expect(mockedFs.readFileSync).toHaveBeenCalledWith(mockFilePath)
expect(mockedFs.readFileSync).toHaveBeenCalledWith(
mockedParameters.schemaPath
)
})
it('generates correct types', async () => {
const expectedTypes = `${mockedCompiledTypes}${mockedRootType}`
const expectedTypes = `${mockedCompiledTypes}${expectedRootType}`
const typeParams = mockedParameters.types as TypesParameters
await generateTypeFromSchema(mockFilePath)
await generateTypeFromSchema(mockedParameters)
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(
mockGeneratedTypesPath,
typeParams.filePath,
expectedTypes
)
})
......@@ -88,7 +103,7 @@ describe('generateTypeFromSchema()', () => {
it('throws when top-level schema definition does not have a title field', async () => {
mockedFs.readFileSync.mockReturnValueOnce(mockedSchemaStringWithoutTitle)
await expect(generateTypeFromSchema(mockFilePath)).rejects.toThrowError(
await expect(generateTypeFromSchema(mockedParameters)).rejects.toThrowError(
Error
)
})
......@@ -98,6 +113,8 @@ describe('generateTypeFromSchema()', () => {
mockedSchemaStringWithInvalidTitle
)
await expect(generateTypeFromSchema(mockFilePath)).rejects.toThrow(Error)
await expect(generateTypeFromSchema(mockedParameters)).rejects.toThrow(
Error
)
})
})
......@@ -2,6 +2,8 @@ import { compileFromFile } from 'json-schema-to-typescript'
import fs from 'fs'
import R from 'ramda'
import { Parameters } from '../params'
// json-schema-to-typescript uses a `toSafeString(string)` function https://github.com/bcherny/json-schema-to-typescript/blob/f41945f19b68918e9c13885f345cb708e1d9898a/src/utils.ts#L163) to obtain a normalized string. This pascalCase mimics this functionality and should address most cases.
export const pascalCase = (input: string): string =>
input
......@@ -9,12 +11,18 @@ export const pascalCase = (input: string): string =>
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
export const generateTypeFromSchema = async (
filePath: string
): Promise<void> => {
const baseTypes = await compileFromFile(filePath)
export const generateTypeFromSchema = async ({
runtimeEnvName,
schemaPath,
types,
}: Parameters): Promise<void> => {
if (types === false) {
return
}
const baseTypes = await compileFromFile(schemaPath)
const schemaString = fs.readFileSync(filePath).toString()
const schemaString = fs.readFileSync(schemaPath).toString()
const title = R.prop('title', JSON.parse(schemaString))
if (title === undefined) {
......@@ -27,13 +35,13 @@ export const generateTypeFromSchema = async (
)
}
const configInterfaceAsString = `export interface Config extends ${pascalCase(
title
)} {
runtimeEnvironment: string;
const configInterfaceAsString = `export interface ${
types.rootTypeName
} extends ${pascalCase(title)} {
${runtimeEnvName}: string;
}
`
const exportedTypes = baseTypes.concat(configInterfaceAsString)
fs.writeFileSync('strong-config.d.ts', exportedTypes)
fs.writeFileSync(types.filePath, exportedTypes)
}
jest.mock('./substitute-with-env')
import { substituteWithEnv } from './substitute-with-env'