From 5886934182a02a43e68ece5cc88741b02eb438c5 Mon Sep 17 00:00:00 2001 From: Pascal Perrenoud Date: Mon, 9 Jun 2025 00:00:13 +0200 Subject: [PATCH] Implement the whole thing --- README.md | 6 ++++ index.ts | 27 +++++++++++++++ package.json | 4 ++- src/helpers.ts | 1 + src/read_env.ts | 38 +++++++++++++++++++++ src/read_type.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 src/helpers.ts create mode 100644 src/read_env.ts create mode 100644 src/read_type.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..249dcc7 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Config +Parse a configuration from env values using a description scheme (using Zod). + +Keys are the name given in the TypeBox scheme, in capital case, separated with an underscore. Sub-objects' names will start with parent's name, followed by an underscore then the key. + +**Any** key can be followed by `__FILE`. In this case, the given value is expected to be a path and the content of the file will be used to be the content of the config. The content of the file will be trimmed from any whitespace character. The file is read using UTF-8 encoding. diff --git a/index.ts b/index.ts index e69de29..07f5873 100644 --- a/index.ts +++ b/index.ts @@ -0,0 +1,27 @@ +import {type Static, type TObject, type TProperties} from '@sinclair/typebox' +import {Value} from '@sinclair/typebox/value' +import logger from 'log' + +import {read_object} from './src/read_type' + +const log = logger('config') +export {Type} from '@sinclair/typebox' // Re-export Type so users can describe config + +export async function parse(scheme: TObject): Promise> | null> { + log.info("Parse configuration from env") + + log.debug("Read configuration from env") + const config = await read_object(scheme) + if (!config.ok) return null + + // This check is kind of a duplicate, is it useful ? + log.debug("Validate config against scheme") + const config_parsed = Value.Check(scheme, config.data) + if (config_parsed) { + log.trace("Config is valid") + return config.data + } else { + log.warn("Invalid config, check failed") + return null + } +} diff --git a/package.json b/package.json index 7cdffb2..d8e7139 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "dependencies": { "@sinclair/typebox": "^0.34.33", - "dotenv": "^16.5.0", "log": "git+https://git.pband.ch/typescript/log.git" }, "devDependencies": { "@types/bun": "^1.2.15", "@types/node": "^22.15.30", + "@types/tmp": "^0.2.6", "bun": "^1.2.15", + "logger-console": "git+https://git.pband.ch/typescript/logger-console.git", + "tmp": "^0.2.3", "typescript": "^5.8.3" }, diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..b73984f --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1 @@ +export type Ok = {ok: true, data: T} | {ok: false} diff --git a/src/read_env.ts b/src/read_env.ts new file mode 100644 index 0000000..0460733 --- /dev/null +++ b/src/read_env.ts @@ -0,0 +1,38 @@ +import logger from 'log' +import fs from 'node:fs/promises' + +import type {Ok} from './helpers' + +const log = logger('config:read_env') +const FILE_EXT = "__FILE" + +// Read from env or file +export async function read_env(key: string): Promise> { + const path = process.env[key + FILE_EXT] + if (path !== undefined) return from_file(path) + else return {ok: true, data: process.env[key]} +} +async function from_file(path: string): Promise> { + log.debug("Read a key from a file") + log.trace("Path :", path) + + let content = await get_file_content(path) + if (!content.ok) return content + + content.data = content.data.trim() + + return content +} +async function get_file_content(path: string) : Promise> { + log.debug('Read file content') + log.trace('Path :', path) + + try { + const data = await fs.readFile(path, {encoding: 'utf-8'}) + return {ok: true, data} + } catch (e) { + log.warn('Failed to read file', path) + log.debug('Error :', e) + return {ok: false} + } +} diff --git a/src/read_type.ts b/src/read_type.ts new file mode 100644 index 0000000..cc254a7 --- /dev/null +++ b/src/read_type.ts @@ -0,0 +1,88 @@ +import { + OptionalKind, + Type, + type Static, + type TDate, + type TObject, + type TProperties, + type TSchema, type TString +} from '@sinclair/typebox' +import {Value} from '@sinclair/typebox/value' +import logger from 'log' + +import type {Ok} from './helpers' +import {read_env} from './read_env' + + +const log = logger('config:read_type') + +// Read each TypeBox type from env +export async function read_object(scheme: TObject, base_name: string = "") : Promise>>> { + log.debug("Read object from env") + if (base_name.length !== 0) base_name = base_name + "_" + + const data: Static> = Value.Create(scheme) + + for (const key in scheme.properties) { + const sub_key = base_name + key.toUpperCase() + + // Get sub-scheme + const sub_scheme = scheme.properties[key] + + // Read value + let value: Ok; + if (sub_scheme.type === 'object') { + value = await read_object(sub_scheme as unknown as TObject, sub_key) + } else if (sub_scheme.type === 'Date') { + value = await read_date(sub_scheme as unknown as TDate, sub_key) + } else { + value = await read_type(sub_scheme, sub_key) + } + // If read failed, early return the error + if (!value.ok) return value + + // If optional, don't store useless data + if (value.data === undefined) continue + + // Set value + // @ts-expect-error : it would be nice to fix this :) + data[key] = value.data + } + + return {ok: true, data} +} + +export async function read_type(scheme: T, name: string) : Promise | undefined>> { + let value = await read_env(name) + if (!value.ok) return value + + if (value.data === undefined && scheme[OptionalKind] !== undefined) return {ok: true, data: undefined} + + try { + const data = Value.Parse(scheme, value.data) + return {ok: true, data} + } catch (e) { + log.warn(`Parsing of key '${name}' failed (invalid value : '${value.data}')`) + log.debug('Error :', e) + return {ok: false} + } +} + +export async function read_date(scheme: TDate, name: string): Promise | undefined>> { + let string_scheme: TString; + if (scheme[OptionalKind] !== undefined) string_scheme = Type.Optional(Type.String()) + else string_scheme = Type.String() + + const value = await read_type(string_scheme, name) + if (!value.ok) return value + if (value.data === undefined) return {ok: true, data: undefined} + + const data = new Date(value.data) + const checked = Value.Check(scheme, data) + if (!checked) { + log.warn(`Invalid date for '${name}' : ${value.data}`) + return {ok: false} + } + + return {ok: true, data} +}