Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Configuru is a library for configuration management. Merge default project confi
- :blue_heart: Typescript friendly
- :muscle: Designed for multi-environment apps
- :see_no_evil: Anonymized configuration for logger
- ♻️ Allows reloading injected config file during runtime
- 💬 JSONC support

## Getting started
Expand All @@ -45,19 +46,20 @@ npm install configuru
4. Create a configuration module (e.g. `config.ts`)

```typescript
import { createLoader, values } from 'configuru'
import { createLoader, schema } from 'configuru'

// create loader that cascades overrides and creates a config storage
const loader = createLoader()

// Pass configuration schema to `values` transformer to get configuration
export default values({
// Pass configuration structure to loader transformer to get configuration schema
const config = loader({
server: {
// use loader accessors, place them in custom structure
// use schema definition, place them in custom structure
// loader parses correct type from store
port: loader.number('SERVER_PORT'),
port: schema.number('SERVER_PORT'),
},
})
// Use loaded values
export default config.values()
```

5. Use your configuration params throughout your app
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createLoader } from './lib/loader'
export { values, maskedValues } from './lib/polishers'
export { schema } from './lib/schema'
29 changes: 29 additions & 0 deletions src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,32 @@ export const JSONC = {
return parsed
},
}

export const traverseObject =
<Leaf>(isLeaf: (v: any) => v is Leaf) =>
(fn: (v: Leaf, key?: string) => any) =>
(val: any): any => {
const mapSchemaValue = (value: any, key?: string): any => {
if (isLeaf(value)) {
const transformed = fn(value, key)
return isLeaf(transformed)
? mapSchemaValue(transformed, key)
: transformed
}
if (Array.isArray(value)) {
// eslint-disable-next-line sonarjs/no-nested-functions
return value.map((val, index) =>
mapSchemaValue(val, key ? `${key}[${index}]` : undefined)
)
}
if (isObject(value)) {
const result: Record<string, any> = {}
for (const key of Object.keys(value)) {
result[key] = mapSchemaValue(value[key], key)
}
return result
}
return value
}
return { ...mapSchemaValue(val) }
}
135 changes: 98 additions & 37 deletions src/lib/loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { anonymize, parseBool } from './helpers'
import { traverseObject, anonymize, isObject } from './helpers'
import { createPolishFunctions, Values, AnonymousValues } from './polishers'
import { isValueDefinition, SchemaDef, ValueDefinition } from './schema'
import { createConfigStorage } from './storage'

export interface ConfigLoaderOptions {
Expand All @@ -18,64 +20,123 @@ export interface LoadedValue<
N extends boolean,
R = N extends false ? T : T | null
> {
key?: string
rawValue: any
value: R
hidden: boolean
nullable: boolean
__CONFIGURU_LEAF: true
__CONFIGURU_LEAF_LOADED: true
}

export const createAtomLoaderFactory = (storage: Record<any, any>) => {
const load =
<T, N extends boolean>(
transform: (x: any) => T,
hidden: boolean,
nullable: boolean
) =>
(key: string): LoadedValue<T, N> => {
type TransformSchemaDef<T> = T extends ValueDefinition<infer V>
? LoadedValue<V, T['nullable'] extends true ? true : false>
: T extends Array<infer U>
? Array<TransformSchemaDef<U>>
: T extends Record<string, any>
? T extends ValueDefinition<any>
? T
: {
[K in keyof T]: TransformSchemaDef<T[K]>
}
: T

const mapSchemaDef = traverseObject(isValueDefinition)

type LoaderResult<S extends SchemaDef> = {
values: () => Values<TransformSchemaDef<S>>
maskedValues: () => AnonymousValues<TransformSchemaDef<S>>
reload: () => LoaderResult<S>
}

type LoaderFn = <S extends SchemaDef>(schema: S) => LoaderResult<S>

const createLoadFn =
(opts: ConfigLoaderOptions): LoaderFn =>
<S extends SchemaDef>(schema: S) => {
const storage = createConfigStorage(opts)

// Helper function to load a single value definition
const loadValueDefinition = (
def: ValueDefinition<any>,
configKey?: string
): LoadedValue<any, any> => {
const key = def.key ?? configKey
if (key === undefined) {
throw new Error(
`Missing key for value definition ${JSON.stringify(
def
)}. Your config schema needs to be an object { key: <schema definition> }`
)
}
const value = storage[key]

const safeTransform = (x: any) => {
try {
return transform(x)
return def.transform(x)
} catch (_error) {
const failedValue = hidden ? anonymize(value) : String(value)
const failedValue = def.hidden ? anonymize(value) : String(value)
throw new Error(
`Failed to transform value >${failedValue}< from key >${key}<`
)
}
}

const missing = value === undefined || value === null
if (!nullable && missing) {
if (!def.nullable && missing) {
throw new Error(`Missing required value ${key}`)
}

const transformedValue = missing ? null : safeTransform(value)

const processNestedSchemas = (val: any): any => {
if (val === null || val === undefined || typeof val !== 'object') {
return val
}
if (isValueDefinition(val)) {
return loadValueDefinition(val).value
}
if (Array.isArray(val)) {
return val.map(processNestedSchemas)
}
if (isObject(val)) {
const result: Record<string, any> = {}
for (const k of Object.keys(val)) {
result[k] = processNestedSchemas(val[k])
}
return result
}
return val
}

const processedValue = def.isCustom
? processNestedSchemas(transformedValue)
: transformedValue

return {
hidden,
nullable,
key,
rawValue: value,
value: missing ? null : (safeTransform(value) as any),
__CONFIGURU_LEAF: true,
nullable: def.nullable ?? false,
hidden: def.hidden ?? false,
value: processedValue as any,
__CONFIGURU_LEAF_LOADED: true,
}
}
return <T>(transform: (x: any) => T) =>
Object.assign(load<T, false>(transform, false, false), {
hidden: Object.assign(load<T, false>(transform, true, false), {
nullable: load<T, true>(transform, true, true),
}),
nullable: Object.assign(load<T, true>(transform, false, true), {
hidden: load<T, true>(transform, true, true),
}),
})
}

export const createLoader = (opts: ConfigLoaderOptions = defaultOpts) => {
opts = { ...defaultOpts, ...opts }
const configStorage = createConfigStorage(opts)
const atomLoader = createAtomLoaderFactory(configStorage)
return {
number: atomLoader(Number),
string: atomLoader(String),
bool: atomLoader(parseBool),
json: atomLoader(JSON.parse),
custom: <T>(fn: (x: any) => T) => atomLoader(fn),
const load = mapSchemaDef((def, configKey) =>
loadValueDefinition(def, configKey)
)

const config: TransformSchemaDef<S> = load(schema)

return {
...createPolishFunctions(config),
reload: () => createLoadFn(opts)(schema),
}
}

export const createLoader = (
opts: ConfigLoaderOptions = defaultOpts
): LoaderFn => {
opts = { ...defaultOpts, ...opts }
return createLoadFn(opts)
}
42 changes: 18 additions & 24 deletions src/lib/polishers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { anonymize, isObject } from './helpers'
import { anonymize, traverseObject } from './helpers'
import { LoadedValue } from './loader'

type LoadedToValue<X> = X extends LoadedValue<any, any>
Expand All @@ -7,7 +7,7 @@ type LoadedToValue<X> = X extends LoadedValue<any, any>
: X['value']
: X

type Values<C> = C extends (...args: any[]) => any
export type Values<C> = C extends (...args: any[]) => any
? C
: C extends Record<any, any>
? {
Expand All @@ -17,7 +17,7 @@ type Values<C> = C extends (...args: any[]) => any
}
: C

type AnonymousValues<C> = C extends (...args: any[]) => any
export type AnonymousValues<C> = C extends (...args: any[]) => any
? C
: C extends Record<any, any>
? {
Expand All @@ -27,30 +27,24 @@ type AnonymousValues<C> = C extends (...args: any[]) => any
}
: C

const isLoadedValue = (x: any) =>
Object.keys(x ?? {}).includes('__CONFIGURU_LEAF')
const isLoadedValue = (x: any): x is LoadedValue<any, any, any> => {
const keys = Object.keys(x ?? {})
return keys.includes('__CONFIGURU_LEAF_LOADED')
}

const mapConfig =
(fn: (v: LoadedValue<any, any>) => any) =>
(val: any): any => {
if (isLoadedValue(val)) {
return mapConfig(fn)(fn(val))
}
if (Array.isArray(val)) {
return val.map(mapConfig(fn))
}
if (isObject(val)) {
return Object.keys(val).reduce((res: any, key) => {
res[key] = mapConfig(fn)(val[key])
return res
}, {})
}
return val
}
const mapLoadedConfig = traverseObject(isLoadedValue)

export const values = mapConfig(x => x.value) as <T extends Record<any, any>>(
const values = mapLoadedConfig(x => x.value) as <T extends Record<any, any>>(
config: T
) => Values<T>
export const maskedValues = mapConfig(x =>

const maskedValues = mapLoadedConfig(x =>
x.hidden ? anonymize(x.rawValue) : x.value
) as <T extends Record<any, any>>(config: T) => AnonymousValues<T>

export const createPolishFunctions = <T extends Record<any, any>>(
config: T
) => ({
values: () => values(config),
maskedValues: () => maskedValues(config),
})
61 changes: 61 additions & 0 deletions src/lib/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { parseBool } from './helpers'

export type TransformFn<T> = (x: any) => T

export type ValueDefinition<T> = {
key?: string
hidden?: boolean
nullable?: boolean
transform: TransformFn<T>
isCustom?: boolean
__CONFIGURU_LEAF: true
}

export type SchemaDef = {
[key: string]: ValueDefinition<any> | SchemaDef
}

const register =
<T, K extends string>(
transform: TransformFn<T>,
hidden: boolean,
nullable: boolean,
isCustom: boolean
) =>
<S extends K>(key?: S): ValueDefinition<T> => {
return {
key,
hidden,
nullable,
transform,
isCustom,
__CONFIGURU_LEAF: true,
}
}

export const createSchemaFn = <T>(transform: (x: any) => T, isCustom = false) =>
Object.assign(register<T, string>(transform, false, false, isCustom), {
hidden: Object.assign(
register<T, string>(transform, true, false, isCustom),
{
nullable: register<T, string>(transform, true, true, isCustom),
}
),
nullable: Object.assign(
register<T, string>(transform, false, true, isCustom),
{
hidden: register<T, string>(transform, true, true, isCustom),
}
),
})

export const schema = {
number: createSchemaFn(Number, false),
string: createSchemaFn(String, false),
bool: createSchemaFn(parseBool, false),
json: createSchemaFn(JSON.parse, false),
custom: <T>(fn: (x: any) => T) => createSchemaFn(fn, true),
}

export const isValueDefinition = (x: any): x is ValueDefinition<any> =>
Object.keys(x ?? {}).includes('__CONFIGURU_LEAF')
4 changes: 2 additions & 2 deletions src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ConfigLoaderOptions } from './loader'
const fileExistsSync = (path: string) => {
try {
return statSync(path).isFile()
} catch (_error) {
} catch {
return false
}
}
Expand Down Expand Up @@ -45,7 +45,7 @@ const loadFile = (filePath?: string) => {
try {
return JSONC.parse(readFileSync(resolvedPath, 'utf-8'))
} catch (_error) {
throw new Error(`Invalid config file in ${resolvedPath}\n`)
throw new Error(`Invalid config file in ${resolvedPath}`)
}
}

Expand Down
Loading