26 Commits

Author SHA1 Message Date
pascal 4348479fcc get rid of log 2026-05-05 17:05:33 +02:00
pascal fda86493b8 remove double check\nTODO: Fix it and re-enable it 2025-10-05 16:51:47 +02:00
pascal c021954eac double-check : Use validate and log error 2025-10-05 16:50:25 +02:00
pascal 53750e51f5 Remove whitespaces 2025-09-12 11:09:48 +02:00
pascal 98995d09a1 CI: update trigger path 2025-09-12 11:08:39 +02:00
pascal d28d2d286c TODOs are done 2025-09-12 11:01:43 +02:00
pascal f9a299863e Add a function to generate the doc 2025-09-12 11:01:26 +02:00
pascal dba1f5b80a Move parsing function to file 2025-09-12 10:42:23 +02:00
pascal e397c5217a fmt 2025-09-12 10:33:16 +02:00
pascal 0f0edda91a Add @types/tmp 2025-09-12 10:33:10 +02:00
pascal f6701e97cd Split and update tests 2025-09-12 10:32:46 +02:00
pascal 00ba7e5587 prettier : don't ignore tests 2025-09-12 10:24:24 +02:00
pascal 7762154d32 Parsing : Add array support, refactor env reading and default/missing management 2025-09-12 09:49:30 +02:00
pascal 2d6f8f6730 Export env functions for test and add log 2025-09-12 09:48:47 +02:00
pascal f7bf9fe29f fix lint errors 2025-09-12 08:44:34 +02:00
pascal f0614b09c7 ci : split all steps of check 2025-09-12 08:43:37 +02:00
pascal d718330fba Add command check:fmt 2025-09-12 08:43:18 +02:00
pascal 7f295af5e7 Add tests to check command 2025-09-12 08:40:31 +02:00
pascal ca346e34ab Update README 2025-09-12 08:27:41 +02:00
pascal f3b6393fb5 Update TODOs 2025-09-11 17:16:24 +02:00
pascal f1898ff0fc fmt 2025-09-11 17:15:02 +02:00
pascal ecd439dcd1 WIP : tests 2025-09-11 17:13:19 +02:00
pascal 7bed7fe9a7 implement config 2025-09-11 17:13:10 +02:00
pascal ec81ff7b01 Update package infos 2025-09-11 13:45:35 +02:00
pascal d8579bdcd6 Add library list 2025-09-11 13:44:04 +02:00
pascal b4bb3e3eed init repo 2025-09-11 13:41:58 +02:00
22 changed files with 538 additions and 435 deletions
+3 -4
View File
@@ -1,7 +1,6 @@
bun.lockb
package-lock.json
node_modules/
.idea/
._*
.idea/
.DS_Store
node_modules/
dist/
+7
View File
@@ -0,0 +1,7 @@
.woodpecker/
test/
.gitignore
README.md
.prettierignore
.prettierrc
eslint.config.ts
+3
View File
@@ -0,0 +1,3 @@
.woodpecker
node_modules
dist
+11
View File
@@ -0,0 +1,11 @@
{
"arrowParens": "avoid",
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": false,
"printWidth": 120,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "none"
}
+41 -7
View File
@@ -1,17 +1,51 @@
when:
- path:
include: [
'src/**/*',
'test/**/*',
'index.ts'
]
include: ['**/*.{js,jsx,ts,tsx}']
steps:
test:
install:
image: node
when:
- event: [pull_request, push, manual]
commands:
- npm install
- npm run check
typecheck:
image: node
when:
- event: [pull_request, push, manual]
depends_on: install
commands:
- npm run typecheck
lint:
image: node
when:
- event: [pull_request, push, manual]
depends_on: install
commands:
- npm run lint
fmt:
image: node
when:
- event: [pull_request, push, manual]
depends_on: install
commands:
- npm run check:fmt
test:
image: node
when:
- event: [pull_request, push, manual]
depends_on: install
commands:
- npm run test
check:
image: node
when:
- event: [pull_request, push, manual]
depends_on: install
commands:
- npm run check
+9 -4
View File
@@ -1,6 +1,11 @@
# Config
Parse a configuration from env values using a description scheme (using Zod).
Describe the configuration you expect and let this lib parse it from env variables or files.
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.
## File parsing
1. Add `__FILE` (note the **2** `_`) at the end of any variable name
2. Set a path to a file as a value
3. The parser will
1. Read the path
2. Read the file
3. Trim any leading or trailing whitespace
4. Use the value for the config
-2
View File
@@ -1,2 +0,0 @@
[test]
preload = "./test/init.ts"
+15
View File
@@ -0,0 +1,15 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import {defineConfig, globalIgnores} from 'eslint/config'
export default defineConfig([
tseslint.configs.recommended,
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
plugins: {js},
extends: ['js/recommended'],
languageOptions: {globals: {...globals.browser, ...globals.node}}
},
globalIgnores(['dist'])
])
-28
View File
@@ -1,28 +0,0 @@
import {z, type ZodObject, type ZodRawShape} from 'zod'
import logger from 'log'
import {read_object} from './src/read_type'
const log = logger('config')
// TODO : export {Type, type Static, type StaticDecode} from '@sinclair/typebox' // Re-export Type so users can describe config
// TODO : export {Value} from '@sinclair/typebox/value'
export async function parse<T extends ZodRawShape>(scheme: ZodObject<T>): Promise<z.infer<ZodObject<T>> | 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 = await scheme.safeParseAsync(config.data)
if (config_parsed.success) {
log.trace("Config is valid")
return config_parsed.data
} else {
log.warn("Invalid config, check failed")
log.debug("Error :", config_parsed.error)
return null
}
}
+27 -20
View File
@@ -1,31 +1,38 @@
{
"scripts": {
"check": "clear ; npm run typecheck && npm run lint && npm run check:fmt && npm run test && clear && echo 'OK'",
"check:fmt": "npx prettier -c **/*.{js,jsx,ts,tsx}",
"fmt": "prettier --write **/*.{js,jsx,ts,tsx}",
"lint": "eslint **/*.{js,jsx,ts,tsx}",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@sinclair/typebox": "^0.34.33",
"log": "git+https://git.pband.ch/typescript/log.git",
"zod": "^3.25.60"
"yup": "^1.7.0"
},
"devDependencies": {
"@types/bun": "^1.2.15",
"@types/node": "^22.15.30",
"@eslint/js": "^9.35.0",
"@types/bun": "^1.2.21",
"@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"
},
"scripts": {
"test": "bun test",
"check": "tsc --noEmit"
"bun": "^1.2.21",
"eslint": "^9.35.0",
"globals": "^16.4.0",
"jiti": "^2.5.1",
"prettier": "^3.6.2",
"tmp": "^0.2.5",
"typescript": "^5.9.2",
"typescript-eslint": "^8.43.0"
},
"name": "config",
"description": "Parse env to create a configuration from a description",
"description": "Parse configuration from env or files",
"version": "1.0.0",
"author": "Pascal Perrenoud <pascal@pband.ch>",
"module": "index.ts",
"type": "module",
"files": [
"index.ts",
"src",
"tsconfig.json"
]
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
+40
View File
@@ -0,0 +1,40 @@
import * as yup from 'yup'
type DocElement = {
name: string
required: boolean
default_value?: unknown
}
export function doc<S extends yup.Maybe<yup.AnyObject>>(
scheme: yup.ObjectSchema<S>,
base_name: string = ''
): DocElement[] {
if (base_name.length !== 0) base_name = base_name + '_'
let elements: DocElement[] = []
for (const key in scheme.fields) {
const sub_key = base_name + key.toUpperCase()
const sub_scheme = scheme.fields[key] as yup.AnySchema
const type = sub_scheme.describe().type
if (type === 'object') {
const sub_elements = doc(sub_scheme as yup.ObjectSchema<S>, sub_key)
elements = [...elements, ...sub_elements]
} else {
elements.push(generic(sub_scheme, sub_key))
}
}
return elements
}
function generic(scheme: yup.AnySchema, name: string): DocElement {
const default_value = scheme.getDefault()
return {
name,
required: !scheme.spec.optional,
default_value
}
}
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs/promises'
import type {Ok} from './helpers'
const FILE_EXT = '__FILE'
// Read from env or file
export async function read_env(key: string): Promise<Ok<string | undefined>> {
const path = process.env[key + FILE_EXT]
if (path !== undefined) {
console.debug(`Read ${key} from a file`)
console.trace('Path :', path)
return from_file(path)
} else {
console.debug(`Read ${key} from env`)
return {ok: true, data: process.env[key]}
}
}
export async function from_file(path: string): Promise<Ok<string>> {
const content = await get_file_content(path)
if (!content.ok) return content
content.data = content.data.trim()
return content
}
export async function get_file_content(path: string): Promise<Ok<string>> {
console.debug('Read file content')
console.trace('Path :', path)
try {
const data = await fs.readFile(path, {encoding: 'utf-8'})
return {ok: true, data}
} catch (e) {
console.warn('Failed to read file', path)
console.debug('Error :', e)
return {ok: false}
}
}
+1 -1
View File
@@ -1 +1 @@
export type Ok<T> = {ok: true, data: T} | {ok: false}
export type Ok<T> = {ok: true; data: T} | {ok?: false}
+3
View File
@@ -0,0 +1,3 @@
export * as yup from 'yup'
export {parse} from './parsing'
export {doc} from './doc'
+90
View File
@@ -0,0 +1,90 @@
import * as yup from 'yup'
import {read_env} from './env'
import type {Ok} from './helpers'
export async function parse<S extends yup.Maybe<yup.AnyObject>>(schema: yup.ObjectSchema<S>): Promise<Ok<S>> {
console.log('Parse from env')
console.trace('Start parsing')
const config = await object(schema)
if (!config.ok) return config
/*log.trace('double-check')
try {
await schema.validate(config.data, {strict: true})
} catch (e) {
log.error('Double-check failed')
log.debug('Config', config.data)
log.debug('Error:', e)
return {ok: false}
}*/
return config
}
export async function object<S extends yup.Maybe<yup.AnyObject>>(
schema: yup.ObjectSchema<S>,
base_name: string = ''
): Promise<Ok<S>> {
console.debug('Object')
if (base_name.length !== 0) base_name = base_name + '_'
// @ts-expect-error Ugly hack with type S
const data: S = {}
for (const key in schema.fields) {
const sub_key = base_name + key.toUpperCase()
const sub_scheme = schema.fields[key] as yup.AnySchema
const type = sub_scheme.describe().type
console.debug(`Parse ${sub_key} with type ${type}`)
// If it is an object, do not try to read from env
let value: Ok<S>
if (type === 'object') {
value = await object(sub_scheme as yup.ObjectSchema<S>, sub_key)
if (!value.ok) return value
} else {
// For all other types, read value from env
const raw = await read_env(sub_key)
if (!raw.ok) return raw
// if undef : might have a default, might be opt, might be missing
if (raw.data === undefined) {
const def = sub_scheme.getDefault()
if (def !== undefined) value = {ok: true, data: def}
else if (sub_scheme.spec.optional) continue
else {
console.warn('Missing value for', key)
return {}
}
} else {
// Normal handling of value (for arrays, split by ',')
const less_raw =
type === 'array' || type === 'tuple' ? (raw.data.length === 0 ? [] : raw.data.split(',')) : raw.data
value = await generic(less_raw, sub_scheme)
if (!value.ok) return value
}
}
data[key] = value.data
}
return {ok: true, data}
}
export async function generic<S>(value: string | string[], scheme: yup.AnySchema<S>): Promise<Ok<S>> {
console.debug('Generic', scheme.describe().type)
if (!(await scheme.isValid(value))) {
console.warn('Invalid value')
console.debug('Value:', value)
return {}
}
const res = scheme.cast(value)
return {ok: true, data: res}
}
-38
View File
@@ -1,38 +0,0 @@
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<Ok<string | undefined>> {
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<Ok<string>> {
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<Ok<string>> {
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}
}
}
-64
View File
@@ -1,64 +0,0 @@
import z, {type $ZodObject, $ZodType} from 'zod/v4/core'
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<T extends $ZodObject>(scheme: T, base_name: string = "") : Promise<Ok<z.infer<T>>> {
log.debug("Read object from env")
if (base_name.length !== 0) base_name = base_name + "_"
const data: z.infer<T> = Object.create({})
for (const key in scheme.shape) {
const sub_key = base_name + key.toUpperCase()
const sub_scheme = scheme.shape[key]
const sub_type = sub_scheme.def.type as string
// Read value
let value: Ok<unknown>;
if (sub_type === 'object') {
value = await read_object(sub_scheme as any, sub_key)
//} else if (sub_type === 'Date') {
//value = await read_date(sub_scheme as any, 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<T extends $ZodType>(scheme: T, name: string) : Promise<Ok<z.infer<T> | undefined>> {
let value = await read_env(name)
if (!value.ok) return value
if (value.data === undefined && scheme.isOptional()) return {ok: true, data: undefined}
try {
const data = await scheme.safeParseAsync(value.data)
if (data.success) return {ok: true, data: data.data}
else {
log.warn(`Parsing of '${name}' failed`)
log.debug('Error :', data.error)
return {ok: false}
}
} catch (e) {
log.warn(`Parsing of key '${name}' failed (invalid value : '${value.data}')`)
log.debug('Error :', e)
return {ok: false}
}
}
+57
View File
@@ -0,0 +1,57 @@
import {test, describe, afterEach, beforeEach, expect} from 'bun:test'
import {type FileResult, fileSync, dirSync, type DirResult} from 'tmp'
import * as fs from 'node:fs/promises'
import * as env from '../src/env'
let file: FileResult
beforeEach(() => (file = fileSync()))
afterEach(() => file.removeCallback())
describe('File IO', () => {
test('Can read file content', async () => {
const message = 'coucou'
await fs.writeFile(file.name, message)
const res = await env.get_file_content(file.name)
expect(res).toEqual({ok: true, data: message})
})
test("Fails safe if file doesn't exist", async () => {
const res = await env.get_file_content('tralalero tralala')
expect(res.ok).not.toBeTrue()
})
test('Reading a file trims whitespaces', async () => {
const message = 'coucou\r\n\n\r\n'
await fs.writeFile(file.name, message)
const res = await env.from_file(file.name)
expect(res).toEqual({ok: true, data: 'coucou'})
})
test.todo('Fails safe if no access to read', () => {})
})
describe('Folder', () => {
let folder: DirResult
beforeEach(() => (folder = dirSync()))
afterEach(() => folder.removeCallback())
test('Fails safe if path points to a folder', async () => {
const res = await env.get_file_content(folder.name)
expect(res.ok).not.toBeTrue()
})
})
describe('read_env', () => {
test('Can get value from env', async () => {
const data = 'coucou'
const key = 'DB_PORT'
process.env[key] = data
const res = await env.read_env(key)
expect(res).toEqual({ok: true, data})
})
test('Can get value from file', async () => {
const data = 'coucou'
const key = 'DB_PORT'
process.env[`${key}__FILE`] = file.name
await fs.writeFile(file.name, data)
const res = await env.read_env(key)
expect(res).toEqual({ok: true, data})
})
})
-5
View File
@@ -1,5 +0,0 @@
import {Console} from 'logger-console'
import {Level, writers} from 'log'
const logger = new Console({minLevel: Level.TRACE, with_color: true})
writers.set('console', logger)
+184
View File
@@ -0,0 +1,184 @@
import {expect, test, describe, beforeEach} from 'bun:test'
import {yup} from '../src/index'
import * as parsing from '../src/parsing'
import {Ok} from '../src/helpers'
beforeEach(() => (process.env = {}))
describe('Object', () => {
test('basic', async () => {
const scheme = yup.object({})
const res = await parsing.object(scheme)
expect(res).toEqual({ok: true, data: {}})
})
describe('empty', () => {
test('Optional', async () => {
const res = await parsing.object(yup.object({thing: yup.number()}))
// @ts-expect-error empty is undefined, duh ??
expect(res).toEqual({ok: true, data: {}})
})
test('Required missing', async () => {
const res = await parsing.object(yup.object({thing: yup.number().required()}))
expect(res.ok).not.toBeTrue()
})
test('default', async () => {
const res = await parsing.object(yup.object({thing: yup.number().default(42)}))
expect(res).toEqual({ok: true, data: {thing: 42}})
})
})
describe('Nested objects', () => {
test('One level', async () => {
process.env = {
NAME: 'test',
PORT: '12',
TS: '2025-06-08 23:51:12'
}
const scheme = yup.object({name: yup.string(), port: yup.number(), ts: yup.date()})
const res = await parsing.object(scheme)
expect(res).toEqual({ok: true, data: {name: 'test', port: 12, ts: new Date('2025-06-08 23:51:12')}})
})
test('Two levels', async () => {
process.env = {
DB_PORT: '12',
DB_HOST: 'localhost',
THING_A: 'Hello!',
THING_B: '212',
NAME: 'Pascal',
AGE: '27'
}
const expected = {
name: 'Pascal',
age: 27,
db: {
port: 12,
host: 'localhost'
},
thing: {
a: 'Hello!',
b: 212
}
}
const scheme = yup.object({
name: yup.string().required(),
age: yup.number().required(),
db: yup
.object({
port: yup.number().required(),
host: yup.string().required()
})
.required(),
thing: yup
.object({
a: yup.string().required(),
b: yup.number().required()
})
.required()
})
const res = await parsing.object(scheme)
expect(res).toEqual({ok: true, data: expected})
})
test('Three levels', async () => {
process.env = {
DB_HOST: 'localhost',
THING_A: 'Hello!',
AGE: '27',
THING_THANG_A: '2025-06-06 23:56:12'
}
const expected = {
age: 27,
db: {
host: 'localhost'
},
thing: {
a: 'Hello!',
thang: {
a: new Date('2025-06-06 23:56:12')
}
}
}
const scheme = yup.object({
age: yup.number(),
db: yup.object({
host: yup.string()
}),
thing: yup.object({
a: yup.string(),
thang: yup.object({
a: yup.date()
})
})
})
const res = await parsing.object(scheme)
expect(res).toEqual({ok: true, data: expected})
})
})
describe('Specific parsing', () => {
async function testing<T>(env_value: string, scheme: yup.AnySchema, should_be_successful: boolean, thing?: T) {
process.env['THING'] = env_value
const my_scheme = yup.object({thing: scheme})
const res: Ok<{thing: T}> = await parsing.object(my_scheme)
// @ts-expect-error annoying typing issue, but it works, so, yk
if (should_be_successful) expect(res).toEqual({ok: true, data: {thing}})
else expect(res.ok).not.toBeTrue()
}
describe('array', () => {
test('basic', () => testing('1,2,3', yup.array(yup.number()), true, [1, 2, 3]))
test('basic with whitespaces', () => testing(' 1 , 2 , 3 ', yup.array(yup.number()), true, [1, 2, 3]))
test('Empty', () => testing('', yup.array(yup.number()), true, []))
test('Empty fails with minimum length', () => testing('', yup.array(yup.number()).min(3), false))
test('With minimum elements', () => testing('1,2,3', yup.array(yup.number()).min(3), true, [1, 2, 3]))
})
describe('tuple', () => {
test('basic', () => testing('1,2,3', yup.tuple([yup.number(), yup.number(), yup.number()]), true, [1, 2, 3]))
test('Missing value', () => testing('1,2', yup.tuple([yup.number(), yup.number(), yup.number()]), false))
test('Too much values', () => testing('1,2,3,4', yup.tuple([yup.number(), yup.number(), yup.number()]), false))
})
})
})
describe('generic', () => {
async function testing(
value: string | string[],
scheme: yup.AnySchema,
should_be_successful: boolean,
data?: unknown
) {
const res = await parsing.generic(value, scheme)
if (should_be_successful) expect(res).toEqual({ok: true, data})
else expect(res.ok).not.toBeTrue()
}
describe('Integer', () => {
test('basic', () => testing('12', yup.number(), true, 12))
test('string', () => testing('coucou', yup.number(), false))
test('min-max OK', () => testing('12', yup.number().min(11).max(13), true, 12))
test('min-max KO', () => testing('10', yup.number().min(11).max(13), false, 10))
test('min-max KO 2', () => testing('10', yup.number().min(11).max(13), false, 14))
})
describe('String', () => {
test('basic', () => testing('coucou', yup.string(), true, 'coucou'))
test('Length min-max', () => testing('coucou', yup.string().max(7), true, 'coucou'))
test('KO: Length min-max', () => testing('coucou', yup.string().max(5), false))
})
describe('boolean', () => {
test('basic true', () => testing('true', yup.boolean(), true, true))
test('basic false', () => testing('false', yup.boolean(), true, false))
test('basic 1', () => testing('1', yup.boolean(), true, true))
test('basic 0', () => testing('0', yup.boolean(), true, false))
test('junk', () => testing('yeet', yup.boolean(), false))
})
describe('Date', () => {
test('basic', () => testing('2025-06-08 22:40:17', yup.date(), true, new Date('2025-06-08 22:40:17')))
})
})
-234
View File
@@ -1,234 +0,0 @@
import {expect, test, describe, beforeEach, afterEach} from 'bun:test'
import {type TSchema, Type} from "@sinclair/typebox"
import {type FileResult, fileSync} from 'tmp'
import * as fs from 'node:fs/promises'
import {z} from 'zod/v4'
import {read_object, read_type} from '../src/read_type'
import {parse} from '..'
beforeEach(() => process.env = {})
async function testing<T extends TSchema>(value: string | undefined, scheme: T, should_be_successful: boolean, data?: any) {
const key = 'DB_PORT'
if (value !== undefined) process.env[key] = value
const res = await read_type(scheme, key)
if (should_be_successful) {
expect(res).toEqual({ok: true, data})
} else {
expect(res).toEqual({ok: false})
}
}
test('tweak', async () => {
const ts = '2025-06-11 09:36:12'
process.env['NAME'] = 'coucou'
process.env['TS'] = ts
process.env['PORT'] = '212'
const scheme = z.object({name: z.string(), port: z.number(), ts: z.date()})
const res = await read_object(scheme)
expect(res).toEqual({ok: true, data: {name: 'coucou', port: 212, ts: new Date(ts)}})
})
// Most important function : read_object
describe('Object', () => {
test('basic', async () => {
const scheme = Type.Object({})
const res = await read_object(scheme)
expect(res).toEqual({ok: true, data: {}})
})
test('One level', async () => {
process.env = {
NAME: 'test',
PORT: '12',
TS: '2025-06-08 23:51:12'
}
const scheme = Type.Object({name: Type.String(), port: Type.Integer(), ts: Type.Date()})
const res = await read_object(scheme)
expect(res).toEqual({ok: true, data: {name: 'test', port: 12, ts: new Date('2025-06-08 23:51:12')}})
})
test('Two levels', async () => {
process.env = {
DB_PORT: '12',
DB_HOST: 'localhost',
THING_A: 'Hello!',
THING_B: '212',
NAME: 'Pascal',
AGE: '27',
}
const expected = {
name: 'Pascal',
age: 27,
db: {
port: 12,
host: 'localhost'
},
thing: {
a: 'Hello!',
b: 212,
}
}
const scheme = Type.Object({
name: Type.String(),
age: Type.Number(),
db: Type.Object({
port: Type.Integer(),
host: Type.String()
}),
thing: Type.Object({
a: Type.String(),
b: Type.Integer(),
})
})
const res = await read_object(scheme)
expect(res).toEqual({ok: true, data: expected})
const res2 = await parse(scheme)
expect(res2).toEqual(expected)
})
test('Three levels', async () => {
process.env = {
DB_HOST: 'localhost',
THING_A: 'Hello!',
AGE: '27',
THING_THANG_A: '2025-06-06 23:56:12'
}
const expected = {
age: 27,
db: {
host: 'localhost'
},
thing: {
a: 'Hello!',
thang: {
a: new Date('2025-06-06 23:56:12')
}
}
}
const scheme = Type.Object({
age: Type.Number(),
db: Type.Object({
host: Type.String()
}),
thing: Type.Object({
a: Type.String(),
thang: Type.Object({
a: Type.Date()
})
})
})
const res = await read_object(scheme)
expect(res).toEqual({ok: true, data: expected})
const res2 = await parse(scheme)
expect(res2).toEqual(expected)
})
})
// Read any from file
describe('from_file', () => {
let file: FileResult | undefined = undefined;
afterEach(() => {
file?.removeCallback()
file = undefined
})
async function testing<T extends TSchema>(value: string | undefined, scheme: T, should_be_successful: boolean, data?: any) {
file = fileSync()
const name = file.name
if (value !== undefined) {
await fs.writeFile(file.name, value)
} else {
file.removeCallback()
file = undefined
}
const key = 'DB_PORT'
process.env[key + '__FILE'] = name
const res = await read_type(scheme, key)
if (should_be_successful) {
expect(res).toEqual({ok: true, data})
} else {
expect(res).toEqual({ok: false})
}
}
test('basic string', () => testing('Coucou', Type.String(), true, 'Coucou'))
test('basic number', () => testing('12', Type.Integer(), true, 12))
test('file does not exist', () => testing(undefined, Type.String(), false))
test('Last empty line is trimmed', () => testing('Coucou\n\r\n ', Type.String(), true, 'Coucou'))
})
// Read generic types
describe('Integer', () => {
test('basic', () => testing('12', Type.Integer(), true, 12))
test('not present', () => testing(undefined, Type.Integer(), false))
test('optional', () => testing(undefined, Type.Optional(Type.Integer()), true))
test('default', () => testing(undefined, Type.Integer({default: 21}), true, 21))
test('string', () => testing('coucou', Type.Integer(), false))
test('min-max OK', () => testing('12', Type.Integer({minimum: 11, maximum: 13}), true, 12))
test('min-max KO', () => testing('10', Type.Integer({minimum: 11, maximum: 13}), false, 10))
})
describe('String', () => {
test('basic', () => testing('coucou', Type.String(), true, 'coucou'))
test('not present', () => testing(undefined, Type.String(), false))
test('optional', () => testing(undefined, Type.Optional(Type.String()), true, undefined))
test('default', () => testing(undefined, Type.String({default: 'yeet'}), true, 'yeet'))
test('Length min-max', () => testing('coucou', Type.String({maxLength: 7}), true, 'coucou'))
test('KO: Length min-max', () => testing('coucou', Type.String({maxLength: 5}), false))
})
describe('boolean', () => {
test('basic true', () => testing('true', Type.Boolean(), true, true))
test('basic 1', () => testing('1', Type.Boolean(), true, true))
test('not present', () => testing(undefined, Type.Boolean(), false))
test('optional', () => testing(undefined, Type.Optional(Type.Boolean()), true))
test('default', () => testing(undefined, Type.Boolean({default: false}), true, false))
})
describe('Enum', () => {
enum TestNumber {
A,
B,
C,
D
}
enum TestString {
A = "a",
B = "b",
}
test('basic number', () => testing('1', Type.Enum(TestNumber), true, TestNumber.B))
test('number out', () => testing('4', Type.Enum(TestNumber), false))
test('basic string', () => testing('a', Type.Enum(TestString), true, TestString.A))
test('string out', () => testing('c', Type.Enum(TestString), false))
test('number with transform', async () => {
const scheme = Type.Transform(Type.String())
.Decode(value => {
value = value.toLowerCase()
if (value === 'a') return TestNumber.A
if (value === 'b') return TestNumber.B
if (value === 'c') return TestNumber.C
if (value === 'c') return TestNumber.D
})
.Encode(value => {
if (value === 0) return 'A'
if (value === 1) return 'B'
if (value === 2) return 'C'
return 'D'
})
await testing('A', scheme, true, TestNumber.A)
})
})
// Read specific types
describe.todo('Array')
describe.todo('Tuple')
+8 -28
View File
@@ -1,34 +1,14 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext","dom"],
"target": "ESNext",
"target": "ES2020",
"module": "ESNext",
"moduleDetection": "force",
"allowJs": true,
"checkJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"moduleResolution": "node",
"declaration": true,
"outDir": "dist",
"rootDir": "./src",
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"forceConsistentCasingInFileNames": true,
"noPropertyAccessFromIndexSignature": false
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"index.ts",
"src/**/*.ts",
]
"include": ["src"]
}