Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(parser): allow to customize interpolation of value #34

Closed
wants to merge 1 commit into from
Closed
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
9 changes: 7 additions & 2 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ export class Env<EnvValues extends Record<string, any>> {
*/
static async create<Schema extends { [key: string]: ValidateFn<unknown> }>(
appRoot: URL,
schema: Schema
schema: Schema,
options?: { onVariableRead?: (key: string, value: string) => string | Promise<string> }
): Promise<
Env<{
[K in keyof Schema]: ReturnType<Schema[K]>
}>
> {
const values = await new EnvProcessor(appRoot).process()
const processor = new EnvProcessor(appRoot, {
onVariableRead: options?.onVariableRead,
})

const values = await processor.process()
const validator = this.rules(schema)
return new Env(validator.validate(values))
}
Expand Down
32 changes: 26 additions & 6 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,23 @@ import dotenv, { DotenvParseOutput } from 'dotenv'
export class EnvParser {
#envContents: string
#preferProcessEnv: boolean = true
#onVariableRead?: (key: string, value: string) => string | Promise<string>

constructor(envContents: string, options?: { ignoreProcessEnv: boolean }) {
constructor(
envContents: string,
options?: {
ignoreProcessEnv?: boolean
onVariableRead?: (key: string, value: string) => string | Promise<string>
}
) {
if (options?.ignoreProcessEnv) {
this.#preferProcessEnv = false
}

if (options?.onVariableRead) {
this.#onVariableRead = options.onVariableRead
}

this.#envContents = envContents
}

Expand Down Expand Up @@ -174,12 +185,21 @@ export class EnvParser {
/**
* Parse the env string to an object of environment variables.
*/
parse(): DotenvParseOutput {
async parse(): Promise<DotenvParseOutput> {
const envCollection = dotenv.parse(this.#envContents.trim())

return Object.keys(envCollection).reduce<DotenvParseOutput>((result, key) => {
result[key] = this.#getValue(key, envCollection)
return result
}, {})
let result: DotenvParseOutput = {}

for (const key in envCollection) {
const value = this.#getValue(key, envCollection)

if (this.#onVariableRead) {
result[key] = await this.#onVariableRead(key, value)
} else {
result[key] = value
}
}

return result
}
}
17 changes: 13 additions & 4 deletions src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,31 @@ export class EnvProcessor {
*/
#appRoot: URL

constructor(appRoot: URL) {
#onVariableRead?: (key: string, value: string) => string | Promise<string>

constructor(
appRoot: URL,
options?: { onVariableRead?: (key: string, value: string) => string | Promise<string> }
) {
this.#appRoot = appRoot
this.#onVariableRead = options?.onVariableRead
}

/**
* Parse env variables from raw contents
*/
#processContents(envContents: string, store: Record<string, any>) {
async #processContents(envContents: string, store: Record<string, any>) {
/**
* Collected env variables
*/
if (!envContents.trim()) {
return store
}

const values = new EnvParser(envContents).parse()
const parser = new EnvParser(envContents, {
onVariableRead: this.#onVariableRead,
})
const values = await parser.parse()
Object.keys(values).forEach((key) => {
let value = process.env[key]

Expand Down Expand Up @@ -70,7 +79,7 @@ export class EnvProcessor {
* Collected env variables
*/
const envValues: Record<string, any> = {}
envFiles.forEach(({ contents }) => this.#processContents(contents, envValues))
await Promise.all(envFiles.map(({ contents }) => this.#processContents(contents, envValues)))
return envValues
}

Expand Down
27 changes: 27 additions & 0 deletions test/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,31 @@ test.group('Env', (group) => {
assert.equal(env.get('PORT'), 3000)
expectTypeOf(env.get('PORT')).toEqualTypeOf<number>()
})

test('validate and process environment variables with custom hook', async ({
assert,
expectTypeOf,
cleanup,
fs,
}) => {
cleanup(() => {
delete process.env.PORT
})

await fs.create('.env', 'PORT=3000')
const env = await Env.create(
fs.baseUrl,
{
PORT: Env.schema.string(),
},
{
onVariableRead(_key, _value) {
return 'abc'
},
}
)

assert.equal(env.get('PORT'), 'abc')
expectTypeOf(env.get('PORT')).toEqualTypeOf<string>()
})
})
24 changes: 20 additions & 4 deletions test/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ test.group('Env Parser', () => {
].join('\n')

const parser = new EnvParser(envString)
const parsed = parser.parse()
const parsed = await parser.parse()
expectTypeOf(parsed).toEqualTypeOf<DotenvParseOutput>()
assert.deepEqual(parsed, {
'PORT': '3333',
Expand Down Expand Up @@ -66,7 +66,7 @@ test.group('Env Parser', () => {
const envString = ['ENV_USER=romain', 'REDIS-USER=$ENV_USER'].join('\n')
const parser = new EnvParser(envString, { ignoreProcessEnv: true })

const parsed = parser.parse()
const parsed = await parser.parse()
expectTypeOf(parsed).toEqualTypeOf<DotenvParseOutput>()
assert.deepEqual(parsed, {
'ENV_USER': 'romain',
Expand All @@ -87,7 +87,7 @@ test.group('Env Parser', () => {
const envString = ['ENV_USER=romain', 'REDIS-USER=$ENV_USER'].join('\n')
const parser = new EnvParser(envString)

const parsed = parser.parse()
const parsed = await parser.parse()
expectTypeOf(parsed).toEqualTypeOf<DotenvParseOutput>()
assert.deepEqual(parsed, {
'ENV_USER': 'virk',
Expand All @@ -108,10 +108,26 @@ test.group('Env Parser', () => {
const envString = ['REDIS-USER=$ENV_USER'].join('\n')
const parser = new EnvParser(envString)

const parsed = parser.parse()
const parsed = await parser.parse()
expectTypeOf(parsed).toEqualTypeOf<DotenvParseOutput>()
assert.deepEqual(parsed, {
'REDIS-USER': 'virk',
})
})

test('use custom hook to interpolate values', async ({ assert }) => {
const envString = ['PORT=3333', 'HOST=127.0.0.1'].join('\n')

const parser = new EnvParser(envString, {
onVariableRead(_key, _value) {
return '1'
},
})
const parsed = await parser.parse()

assert.deepEqual(parsed, {
PORT: '1',
HOST: '1',
})
})
})
26 changes: 26 additions & 0 deletions test/processor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,32 @@ test.group('Env processor', () => {
assert.deepEqual(values, { HOST: 'localhost', PORT: '3000' })
})

test('process .env file with custom hook', async ({ assert, cleanup, fs }) => {
cleanup(() => {
delete process.env.PORT
delete process.env.HOST
})

await fs.create(
'.env',
`
HOST=localhost
PORT=3000
`
)

const app = new EnvProcessor(fs.baseUrl, {
onVariableRead(_key, _value) {
return '1'
},
})

const values = await app.process()
assert.equal(process.env.HOST, '1')
assert.equal(process.env.PORT, '1')
assert.deepEqual(values, { HOST: '1', PORT: '1' })
})

test('process .env.local and .env files', async ({ assert, cleanup, fs }) => {
cleanup(() => {
delete process.env.PORT
Expand Down