Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4348479fcc
|
|||
|
fda86493b8
|
|||
|
c021954eac
|
|||
|
53750e51f5
|
|||
|
98995d09a1
|
|||
|
d28d2d286c
|
|||
|
f9a299863e
|
|||
|
dba1f5b80a
|
|||
|
e397c5217a
|
|||
|
0f0edda91a
|
|||
|
f6701e97cd
|
|||
|
00ba7e5587
|
|||
|
7762154d32
|
|||
|
2d6f8f6730
|
|||
|
f7bf9fe29f
|
|||
|
f0614b09c7
|
|||
|
d718330fba
|
|||
|
7f295af5e7
|
|||
|
ca346e34ab
|
|||
|
f3b6393fb5
|
|||
|
f1898ff0fc
|
|||
|
ecd439dcd1
|
|||
|
7bed7fe9a7
|
|||
|
ec81ff7b01
|
|||
|
d8579bdcd6
|
|||
|
b4bb3e3eed
|
+3
-4
@@ -1,7 +1,6 @@
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
node_modules/
|
||||
.idea/
|
||||
._*
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.woodpecker/
|
||||
test/
|
||||
.gitignore
|
||||
README.md
|
||||
.prettierignore
|
||||
.prettierrc
|
||||
eslint.config.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
.woodpecker
|
||||
node_modules
|
||||
dist
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": false,
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
+41
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
preload = "./test/init.ts"
|
||||
@@ -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'])
|
||||
])
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
export type Ok<T> = {ok: true, data: T} | {ok: false}
|
||||
export type Ok<T> = {ok: true; data: T} | {ok?: false}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * as yup from 'yup'
|
||||
export {parse} from './parsing'
|
||||
export {doc} from './doc'
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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')))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user