989 lines
38 KiB
TypeScript
989 lines
38 KiB
TypeScript
import { describe, it } from "@effect/vitest"
|
|
import { deepStrictEqual } from "@effect/vitest/utils"
|
|
import { ConfigProvider, Effect, FileSystem, Layer, Path, PlatformError, Result } from "effect"
|
|
|
|
async function assertSuccess(
|
|
provider: ConfigProvider.ConfigProvider,
|
|
path: ConfigProvider.Path,
|
|
expected: ConfigProvider.Node | undefined
|
|
) {
|
|
const r = Effect.result(provider.load(path))
|
|
deepStrictEqual(await Effect.runPromise(r), Result.succeed(expected))
|
|
}
|
|
|
|
// async function assertFailure(
|
|
// provider: ConfigProvider.ConfigProvider,
|
|
// path: ConfigProvider.Path,
|
|
// expected: ConfigProvider.SourceError
|
|
// ) {
|
|
// const r = Effect.result(provider.load(path))
|
|
// deepStrictEqual(await Effect.runPromise(r), Result.fail(expected))
|
|
// }
|
|
|
|
describe("ConfigProvider", () => {
|
|
it("orElse", async () => {
|
|
const provider1 = ConfigProvider.fromEnv({
|
|
env: {
|
|
"A": "value1"
|
|
}
|
|
})
|
|
const provider2 = ConfigProvider.fromEnv({
|
|
env: {
|
|
"B": "value2"
|
|
}
|
|
})
|
|
const provider = provider1.pipe(ConfigProvider.orElse(provider2))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["B"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("orElse does not fall back on SourceError", async () => {
|
|
const error = new ConfigProvider.SourceError({ message: "io down" })
|
|
const primary = ConfigProvider.make(() => Effect.fail(error))
|
|
const fallback = ConfigProvider.fromEnv({ env: { KEY: "fallback" } })
|
|
const provider = primary.pipe(ConfigProvider.orElse(fallback))
|
|
const r = await Effect.runPromise(Effect.result(provider.load(["KEY"])))
|
|
deepStrictEqual(r, Result.fail(error))
|
|
})
|
|
|
|
it("orElse applies each operand's transformations", async () => {
|
|
const primary = ConfigProvider.fromEnv({
|
|
env: {
|
|
"DATABASE_HOST": "from-env"
|
|
}
|
|
}).pipe(ConfigProvider.constantCase)
|
|
const fallback = ConfigProvider.fromEnv({
|
|
env: {
|
|
"APP_PORT": "3000"
|
|
}
|
|
}).pipe(ConfigProvider.nested("APP"))
|
|
const provider = primary.pipe(ConfigProvider.orElse(fallback))
|
|
await assertSuccess(provider, ["databaseHost"], ConfigProvider.makeValue("from-env"))
|
|
await assertSuccess(provider, ["PORT"], ConfigProvider.makeValue("3000"))
|
|
})
|
|
|
|
it("mapInput distributes over orElse", async () => {
|
|
const appendSuffix = ConfigProvider.mapInput((path) =>
|
|
path.map((seg) => typeof seg === "string" ? `${seg}_SUFFIX` : seg)
|
|
)
|
|
const primary = ConfigProvider.fromEnv({
|
|
env: {
|
|
"prefix_SUFFIX_KEY_SUFFIX": "primary"
|
|
}
|
|
}).pipe(ConfigProvider.nested("prefix"))
|
|
const fallback = ConfigProvider.fromEnv({
|
|
env: {
|
|
"fallback_SUFFIX_KEY_SUFFIX": "fallback",
|
|
"fallback_SUFFIX_OTHER_SUFFIX": "fallback"
|
|
}
|
|
}).pipe(ConfigProvider.nested("fallback"))
|
|
const provider = primary.pipe(ConfigProvider.orElse(fallback), appendSuffix)
|
|
await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("primary"))
|
|
await assertSuccess(provider, ["OTHER"], ConfigProvider.makeValue("fallback"))
|
|
})
|
|
|
|
it("nested distributes over orElse", async () => {
|
|
const primary = ConfigProvider.fromEnv({
|
|
env: {
|
|
"app_DATABASE_HOST": "primary"
|
|
}
|
|
}).pipe(ConfigProvider.constantCase)
|
|
const fallback = ConfigProvider.fromEnv({
|
|
env: {
|
|
"app_PORT": "fallback"
|
|
}
|
|
})
|
|
const provider = primary.pipe(ConfigProvider.orElse(fallback), ConfigProvider.nested("app"))
|
|
await assertSuccess(provider, ["databaseHost"], ConfigProvider.makeValue("primary"))
|
|
await assertSuccess(provider, ["PORT"], ConfigProvider.makeValue("fallback"))
|
|
})
|
|
|
|
it("orElse falls back when the primary env value is empty", async () => {
|
|
const primary = ConfigProvider.fromEnv({
|
|
env: {
|
|
"KEY": ""
|
|
}
|
|
})
|
|
const fallback = ConfigProvider.fromUnknown({ KEY: "fallback" })
|
|
const provider = primary.pipe(ConfigProvider.orElse(fallback))
|
|
await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("fallback"))
|
|
})
|
|
|
|
it("constantCase", async () => {
|
|
const provider = ConfigProvider.constantCase(ConfigProvider.fromEnv({
|
|
env: {
|
|
"CONSTANT_CASE": "value1"
|
|
}
|
|
}))
|
|
await assertSuccess(provider, ["constant.case"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("constantCase uses config casing for numeric word groups", async () => {
|
|
const provider = ConfigProvider.constantCase(ConfigProvider.fromEnv({
|
|
env: {
|
|
"API_V2_XML": "value1",
|
|
"FIELD2_VALUE": "value2"
|
|
}
|
|
}))
|
|
await assertSuccess(provider, ["api-v2 xml"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["field2Value"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
describe("mapInput", () => {
|
|
it("two mappings", async () => {
|
|
const appendA = ConfigProvider.mapInput((path) => path.map((sn) => typeof sn === "string" ? sn + "_A" : sn))
|
|
const appendB = ConfigProvider.mapInput((path) => path.map((sn) => typeof sn === "string" ? sn + "_B" : sn))
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
"KEY_A_B": "value"
|
|
}
|
|
}).pipe(appendA, appendB)
|
|
await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("value"))
|
|
})
|
|
})
|
|
|
|
describe("nested", () => {
|
|
it("should add a prefix to the path", async () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
"prefix_A": "value"
|
|
}
|
|
}).pipe(ConfigProvider.nested("prefix"))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value"))
|
|
})
|
|
|
|
it("constantCase + nested", async () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
"prefix_KEY_WITH_DOTS": "value"
|
|
}
|
|
}).pipe(ConfigProvider.constantCase, ConfigProvider.nested("prefix"))
|
|
await assertSuccess(provider, ["key.with.dots"], ConfigProvider.makeValue("value"))
|
|
})
|
|
|
|
it("nested + constantCase", async () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
"PREFIX_WITH_DOTS_KEY_WITH_DOTS": "value"
|
|
}
|
|
}).pipe(ConfigProvider.nested("prefix.with.dots"), ConfigProvider.constantCase)
|
|
await assertSuccess(provider, ["key.with.dots"], ConfigProvider.makeValue("value"))
|
|
})
|
|
|
|
it("multiple nested calls compose as wrappers", async () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
"b_a_KEY": "value"
|
|
}
|
|
}).pipe(ConfigProvider.nested("a"), ConfigProvider.nested("b"))
|
|
await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("value"))
|
|
})
|
|
|
|
it("mapInput after nested transforms the full path", async () => {
|
|
const appendLeaf = ConfigProvider.mapInput((path) => [...path, "leaf"])
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
"app_KEY_leaf": "value"
|
|
}
|
|
}).pipe(ConfigProvider.nested("app"), appendLeaf)
|
|
await assertSuccess(provider, ["KEY"], ConfigProvider.makeValue("value"))
|
|
})
|
|
})
|
|
|
|
describe("fromEnv", () => {
|
|
it("env without an underscore", async () => {
|
|
const env = { A: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("missing key returns undefined", async () => {
|
|
const env = { A: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
await assertSuccess(provider, ["missing"], undefined)
|
|
})
|
|
|
|
it("treats empty string values as missing while preserving structure by default", async () => {
|
|
const env = { A: "", B: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A", "B"])))
|
|
await assertSuccess(provider, ["A"], undefined)
|
|
await assertSuccess(provider, ["B"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("preserves empty strings when requested", async () => {
|
|
const env = { A: "" }
|
|
const provider = ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A"])))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue(""))
|
|
})
|
|
|
|
it("treats empty co-located record values as missing and preserves children", async () => {
|
|
const env = { A: "", A_B: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("preserves empty co-located record values when requested", async () => {
|
|
const env = { A: "", A_B: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"]), ""))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("treats empty co-located array values as missing and preserves children", async () => {
|
|
const env = { A: "", A_0: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeArray(1))
|
|
await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("preserves empty array children structurally while treating their values as missing", async () => {
|
|
const env = { A_0: "", A_1: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeArray(2))
|
|
await assertSuccess(provider, ["A", 0], undefined)
|
|
await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("direct lookup of a key containing underscores", async () => {
|
|
const env = { NODE_ENV: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("single underscore creates nesting", async () => {
|
|
const env = { NODE_ENV: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["NODE"], ConfigProvider.makeRecord(new Set(["ENV"])))
|
|
await assertSuccess(provider, ["NODE", "ENV"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("prefix can be both a value and a container", async () => {
|
|
const env = { NODE: "value1", NODE_ENV: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["NODE"], ConfigProvider.makeRecord(new Set(["ENV"]), "value1"))
|
|
await assertSuccess(provider, ["NODE", "ENV"], ConfigProvider.makeValue("value2"))
|
|
await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("multiple nested children under the same prefix", async () => {
|
|
const env = { NODE: "value1", NODE_ENV: "value2", NODE_PATH: "value3" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["NODE"], ConfigProvider.makeRecord(new Set(["ENV", "PATH"]), "value1"))
|
|
await assertSuccess(provider, ["NODE", "ENV"], ConfigProvider.makeValue("value2"))
|
|
await assertSuccess(provider, ["NODE", "PATH"], ConfigProvider.makeValue("value3"))
|
|
await assertSuccess(provider, ["NODE_PATH"], ConfigProvider.makeValue("value3"))
|
|
})
|
|
|
|
it("nested depth > 2 works", async () => {
|
|
const env = { A_B_C: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeRecord(new Set(["C"])))
|
|
await assertSuccess(provider, ["A", "B", "C"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("array: dense numeric children yield an Array node", async () => {
|
|
const env = { A_0: "value1", A_1: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeArray(2))
|
|
await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("array: non-dense numeric children still yield an Array node", async () => {
|
|
const env = { A: "root", A_0: "value1", A_2: "value3" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
// max index is 2 => length 3 (sparse is allowed)
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeArray(3, "root"))
|
|
await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", 1], undefined)
|
|
await assertSuccess(provider, ["A", 2], ConfigProvider.makeValue("value3"))
|
|
})
|
|
|
|
it("array: parent can have a co-located value", async () => {
|
|
const env = { A: "root", A_0: "value1", A_1: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeArray(2, "root"))
|
|
await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("mixed children (numeric + non-numeric) yields Record", async () => {
|
|
const env = { A_0: "value1", A_B: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["0", "B"])))
|
|
await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("double underscore produces an empty segment (no special handling)", async () => {
|
|
const env = { A__B: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set([""])))
|
|
await assertSuccess(provider, ["A", ""], ConfigProvider.makeRecord(new Set(["B"])))
|
|
await assertSuccess(provider, ["A", "", "B"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A__B"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("empty segment node can be both a value and a container", async () => {
|
|
const env = { A_: "value1", A__B: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A", ""], ConfigProvider.makeRecord(new Set(["B"]), "value1"))
|
|
await assertSuccess(provider, ["A", "", "B"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("leading underscore creates an empty first segment", async () => {
|
|
const env = { _A: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set([""])))
|
|
await assertSuccess(provider, [""], ConfigProvider.makeRecord(new Set(["A"])))
|
|
await assertSuccess(provider, ["", "A"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["_A"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("trailing underscore creates an empty last segment", async () => {
|
|
const env = { A_: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set([""])))
|
|
await assertSuccess(provider, ["A", ""], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A_"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("cannot descend past a leaf (A exists, but A_B does not)", async () => {
|
|
const env = { A: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", "B"], undefined)
|
|
await assertSuccess(provider, ["A_B"], undefined)
|
|
})
|
|
|
|
it("direct lookup and nested lookup both work for the same env var", async () => {
|
|
const env = { A_B: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A_B"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("prefix value is preserved when there are multiple nested children", async () => {
|
|
const env = { A: "root", A_B: "value1", A_C: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B", "C"]), "root"))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", "C"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("intermediate node can be both a value and a container (A_B and A_B_C)", async () => {
|
|
const env = { A_B: "value1", A_B_C: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A_B"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["B"])))
|
|
await assertSuccess(provider, ["A", "B"], ConfigProvider.makeRecord(new Set(["C"]), "value1"))
|
|
await assertSuccess(provider, ["A", "B", "C"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("numeric index: string and number segments should resolve the same", async () => {
|
|
const env = { A_0: "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A", 0], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", "0"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("array: leading-zero indices are not numeric, so it yields a Record", async () => {
|
|
const env = { A_01: "value1", A_1: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["01", "1"])))
|
|
await assertSuccess(provider, ["A", "01"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["A", 1], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("root path exposes top-level keys", async () => {
|
|
const env = { A: "value1", B_C: "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A", "B"])))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["B"], ConfigProvider.makeRecord(new Set(["C"])))
|
|
})
|
|
|
|
it("underscore-only key creates empty segments", async () => {
|
|
const env = { "_": "value1" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["_"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set([""])))
|
|
await assertSuccess(provider, [""], ConfigProvider.makeRecord(new Set([""])))
|
|
await assertSuccess(provider, ["", ""], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("array: non-integer indices do not yield an Array node", async () => {
|
|
const env = { "A_-1": "value1", "A_1.5": "value2" }
|
|
const provider = ConfigProvider.fromEnv({ env })
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeRecord(new Set(["-1", "1.5"])))
|
|
})
|
|
})
|
|
|
|
describe("fromUnknown", () => {
|
|
const provider = ConfigProvider.fromUnknown({
|
|
string: "value1",
|
|
record: {
|
|
key1: "value2",
|
|
key2: {
|
|
key3: "value3"
|
|
}
|
|
},
|
|
array: ["value4", {
|
|
key4: "value5"
|
|
}, ["value6"]],
|
|
null: null,
|
|
number: 42,
|
|
boolean: true,
|
|
bigint: 0n,
|
|
undefined,
|
|
unknown: Symbol("unknown")
|
|
})
|
|
|
|
it("Root node", async () => {
|
|
await assertSuccess(
|
|
provider,
|
|
[],
|
|
ConfigProvider.makeRecord(
|
|
new Set(["string", "record", "array", "null", "number", "boolean", "bigint", "undefined", "unknown"])
|
|
)
|
|
)
|
|
})
|
|
|
|
it("Exact leaf resolution", async () => {
|
|
await assertSuccess(provider, ["string"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["record", "key1"], ConfigProvider.makeValue("value2"))
|
|
await assertSuccess(provider, ["array", 0], ConfigProvider.makeValue("value4"))
|
|
await assertSuccess(provider, ["array", 1, "key4"], ConfigProvider.makeValue("value5"))
|
|
await assertSuccess(provider, ["array", 2, 0], ConfigProvider.makeValue("value6"))
|
|
})
|
|
|
|
it("treats empty string leaves as missing by default", async () => {
|
|
const provider = ConfigProvider.fromUnknown({
|
|
a: "",
|
|
b: "value1",
|
|
record: {
|
|
key: ""
|
|
},
|
|
array: [""]
|
|
})
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["a", "b", "record", "array"])))
|
|
await assertSuccess(provider, ["a"], undefined)
|
|
await assertSuccess(provider, ["b"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["record"], ConfigProvider.makeRecord(new Set(["key"])))
|
|
await assertSuccess(provider, ["record", "key"], undefined)
|
|
await assertSuccess(provider, ["array"], ConfigProvider.makeArray(1))
|
|
await assertSuccess(provider, ["array", 0], undefined)
|
|
})
|
|
|
|
it("preserves empty string leaves when requested", async () => {
|
|
const provider = ConfigProvider.fromUnknown(
|
|
{
|
|
a: "",
|
|
array: [""]
|
|
},
|
|
{ preserveEmptyStrings: true }
|
|
)
|
|
|
|
await assertSuccess(provider, ["a"], ConfigProvider.makeValue(""))
|
|
await assertSuccess(provider, ["array", 0], ConfigProvider.makeValue(""))
|
|
})
|
|
|
|
it("Object detection", async () => {
|
|
await assertSuccess(provider, ["record"], ConfigProvider.makeRecord(new Set(["key1", "key2"])))
|
|
await assertSuccess(provider, ["record", "key2"], ConfigProvider.makeRecord(new Set(["key3"])))
|
|
})
|
|
|
|
it("Array detection", async () => {
|
|
await assertSuccess(provider, ["array"], ConfigProvider.makeArray(3))
|
|
await assertSuccess(provider, ["array", 2], ConfigProvider.makeArray(1))
|
|
})
|
|
|
|
it("should return undefined on non-existing paths", async () => {
|
|
await assertSuccess(provider, ["string", "non-existing"], undefined)
|
|
await assertSuccess(provider, ["record", "non-existing"], undefined)
|
|
await assertSuccess(provider, ["array", 3, "non-existing"], undefined)
|
|
})
|
|
|
|
it("null values", async () => {
|
|
await assertSuccess(provider, ["null"], undefined)
|
|
})
|
|
|
|
it("number values", async () => {
|
|
await assertSuccess(provider, ["number"], ConfigProvider.makeValue("42"))
|
|
})
|
|
|
|
it("boolean values", async () => {
|
|
await assertSuccess(provider, ["boolean"], ConfigProvider.makeValue("true"))
|
|
})
|
|
|
|
it("bigint values", async () => {
|
|
await assertSuccess(provider, ["bigint"], ConfigProvider.makeValue("0"))
|
|
})
|
|
|
|
it("undefined values", async () => {
|
|
await assertSuccess(provider, ["undefined"], undefined)
|
|
})
|
|
|
|
it("unknown values", async () => {
|
|
await assertSuccess(provider, ["unknown"], ConfigProvider.makeValue("Symbol(unknown)"))
|
|
})
|
|
})
|
|
|
|
describe("fromDotEnvContents", () => {
|
|
it("comments are ignored", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
# comments are ignored
|
|
API_URL=https://api.example.com
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["API"])))
|
|
await assertSuccess(provider, ["API_URL"], ConfigProvider.makeValue("https://api.example.com"))
|
|
})
|
|
|
|
it("export is allowed", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
export NODE_ENV=production
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["NODE"])))
|
|
await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("production"))
|
|
})
|
|
|
|
it("quoting is allowed", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
NODE_ENV="production"
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["NODE"])))
|
|
await assertSuccess(provider, ["NODE_ENV"], ConfigProvider.makeValue("production"))
|
|
})
|
|
|
|
it("objects are supported", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
OBJECT_key1=value1
|
|
OBJECT_key2=value2
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["OBJECT"])))
|
|
await assertSuccess(provider, ["OBJECT"], ConfigProvider.makeRecord(new Set(["key1", "key2"])))
|
|
await assertSuccess(provider, ["OBJECT", "key1"], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["OBJECT", "key2"], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("treats empty string values as missing while preserving structure by default", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
A=
|
|
B=value1
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A", "B"])))
|
|
await assertSuccess(provider, ["A"], undefined)
|
|
await assertSuccess(provider, ["B"], ConfigProvider.makeValue("value1"))
|
|
})
|
|
|
|
it("preserves empty strings when requested", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(
|
|
`
|
|
A=
|
|
`,
|
|
{ preserveEmptyStrings: true }
|
|
)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A"])))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue(""))
|
|
})
|
|
|
|
it("a node may be both leaf and object", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
OBJECT=value1
|
|
OBJECT_key1=value2
|
|
OBJECT_key2=value3
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["OBJECT"])))
|
|
await assertSuccess(provider, ["OBJECT"], ConfigProvider.makeRecord(new Set(["key1", "key2"]), "value1"))
|
|
await assertSuccess(provider, ["OBJECT", "key1"], ConfigProvider.makeValue("value2"))
|
|
await assertSuccess(provider, ["OBJECT", "key2"], ConfigProvider.makeValue("value3"))
|
|
})
|
|
|
|
it("a node may be both leaf and array", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
ARRAY=value1
|
|
ARRAY_0=value2
|
|
ARRAY_1=value3
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["ARRAY"])))
|
|
await assertSuccess(provider, ["ARRAY"], ConfigProvider.makeArray(2, "value1"))
|
|
await assertSuccess(provider, ["ARRAY", 0], ConfigProvider.makeValue("value2"))
|
|
await assertSuccess(provider, ["ARRAY", 1], ConfigProvider.makeValue("value3"))
|
|
})
|
|
|
|
it("arrays are supported", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
ARRAY_0=value1
|
|
ARRAY_1=value2
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["ARRAY"])))
|
|
await assertSuccess(provider, ["ARRAY"], ConfigProvider.makeArray(2))
|
|
await assertSuccess(provider, ["ARRAY", 0], ConfigProvider.makeValue("value1"))
|
|
await assertSuccess(provider, ["ARRAY", 1], ConfigProvider.makeValue("value2"))
|
|
})
|
|
|
|
it("expansion of environment variables is off by default", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(`
|
|
PASSWORD="value"
|
|
DB_PASS=$PASSWORD
|
|
`)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["PASSWORD", "DB"])))
|
|
await assertSuccess(provider, ["PASSWORD"], ConfigProvider.makeValue("value"))
|
|
await assertSuccess(provider, ["DB_PASS"], ConfigProvider.makeValue("$PASSWORD"))
|
|
})
|
|
|
|
it("expansion of environment variables is supported", async () => {
|
|
const provider = ConfigProvider.fromDotEnvContents(
|
|
`
|
|
PASSWORD="value"
|
|
DB_PASS=$PASSWORD
|
|
`,
|
|
{ expandVariables: true }
|
|
)
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["PASSWORD", "DB"])))
|
|
await assertSuccess(provider, ["PASSWORD"], ConfigProvider.makeValue("value"))
|
|
await assertSuccess(provider, ["DB_PASS"], ConfigProvider.makeValue("value"))
|
|
})
|
|
})
|
|
|
|
describe("fromDotEnv", () => {
|
|
it("should load configuration from .env file", async () => {
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDotEnv().pipe(
|
|
Effect.provide(FileSystem.layerNoop({
|
|
readFileString: (path) =>
|
|
Effect.succeed(`PATH=${path}
|
|
A=1`)
|
|
}))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, ["PATH"], ConfigProvider.makeValue(".env"))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("1"))
|
|
})
|
|
|
|
it("should support `path` option to specify the path to the .env file", async () => {
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDotEnv({ path: "custom.env" }).pipe(
|
|
Effect.provide(FileSystem.layerNoop({
|
|
readFileString: (path) =>
|
|
Effect.succeed(`CUSTOM_PATH=${path}
|
|
A=1`)
|
|
}))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, ["CUSTOM_PATH"], ConfigProvider.makeValue("custom.env"))
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue("1"))
|
|
})
|
|
|
|
it("should support `expandVariables` option", async () => {
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDotEnv({ expandVariables: true }).pipe(
|
|
Effect.provide(FileSystem.layerNoop({
|
|
readFileString: () =>
|
|
Effect.succeed(`PASSWORD=value
|
|
DB_PASS=$PASSWORD`)
|
|
}))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, ["DB_PASS"], ConfigProvider.makeValue("value"))
|
|
})
|
|
|
|
it("should treat empty string values as missing while preserving structure by default", async () => {
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDotEnv().pipe(
|
|
Effect.provide(FileSystem.layerNoop({
|
|
readFileString: () => Effect.succeed("A=")
|
|
}))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["A"])))
|
|
await assertSuccess(provider, ["A"], undefined)
|
|
})
|
|
|
|
it("should support `preserveEmptyStrings` option", async () => {
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDotEnv({ preserveEmptyStrings: true }).pipe(
|
|
Effect.provide(FileSystem.layerNoop({
|
|
readFileString: () => Effect.succeed("A=")
|
|
}))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, ["A"], ConfigProvider.makeValue(""))
|
|
})
|
|
})
|
|
|
|
describe("fromDir", () => {
|
|
const provider = ConfigProvider.fromDir({ rootPath: "/" })
|
|
const files: Record<string, string> = {
|
|
"/secret": "keepitsafe\n",
|
|
"/SHOUTING": "value",
|
|
"/integer": "123",
|
|
"/nested/config": "hello"
|
|
}
|
|
const Fs = FileSystem.layerNoop({
|
|
readFileString(path) {
|
|
if (path in files) {
|
|
return Effect.succeed(files[path])
|
|
}
|
|
return Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readFileString"
|
|
})
|
|
)
|
|
},
|
|
readDirectory(_path) {
|
|
// For the test, we only have files, no directories
|
|
return Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readDirectory"
|
|
})
|
|
)
|
|
}
|
|
})
|
|
const Platform = Layer.mergeAll(Fs, Path.layer)
|
|
const SetLayer = ConfigProvider.layer(provider).pipe(
|
|
Layer.provide(Platform),
|
|
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv({
|
|
env: { secret: "fail" }
|
|
})))
|
|
)
|
|
const AddLayer = ConfigProvider.layerAdd(provider).pipe(
|
|
Layer.provide(Platform),
|
|
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv({
|
|
env: {
|
|
secret: "shh",
|
|
fallback: "value"
|
|
}
|
|
})))
|
|
)
|
|
|
|
it("reads config", async () => {
|
|
const result = await Effect.runPromise(
|
|
Effect.gen(function*() {
|
|
const provider = yield* ConfigProvider.ConfigProvider
|
|
const secret = yield* provider.load(["secret"])
|
|
const shouting = yield* provider.load(["SHOUTING"])
|
|
const integer = yield* provider.load(["integer"])
|
|
const nestedConfig = yield* provider.load(["nested", "config"])
|
|
|
|
return { secret, shouting, integer, nestedConfig }
|
|
}).pipe(Effect.provide(SetLayer))
|
|
)
|
|
|
|
deepStrictEqual(result.secret, ConfigProvider.makeValue("keepitsafe"))
|
|
deepStrictEqual(result.shouting, ConfigProvider.makeValue("value"))
|
|
deepStrictEqual(result.integer, ConfigProvider.makeValue("123"))
|
|
deepStrictEqual(result.nestedConfig, ConfigProvider.makeValue("hello"))
|
|
|
|
const fallback = await Effect.runPromise(
|
|
Effect.gen(function*() {
|
|
const provider = yield* ConfigProvider.ConfigProvider
|
|
return yield* provider.load(["fallback"])
|
|
}).pipe(Effect.provide(SetLayer))
|
|
)
|
|
|
|
deepStrictEqual(fallback, undefined)
|
|
})
|
|
|
|
it("orElse falls back when fromDir path is missing", async () => {
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDir({ rootPath: "/" }).pipe(
|
|
Effect.map((dir) => dir.pipe(ConfigProvider.orElse(ConfigProvider.fromEnv({ env: { fallback: "value" } })))),
|
|
Effect.provide(Platform)
|
|
)
|
|
)
|
|
await assertSuccess(provider, ["fallback"], ConfigProvider.makeValue("value"))
|
|
})
|
|
|
|
it("treats empty files as missing by default", async () => {
|
|
const Fs = FileSystem.layerNoop({
|
|
readFileString(path) {
|
|
return path === "/empty"
|
|
? Effect.succeed("")
|
|
: Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readFileString"
|
|
})
|
|
)
|
|
},
|
|
readDirectory(path) {
|
|
return path === "/"
|
|
? Effect.succeed(["empty"])
|
|
: Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readDirectory"
|
|
})
|
|
)
|
|
}
|
|
})
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDir({ rootPath: "/" }).pipe(Effect.provide(Layer.mergeAll(Fs, Path.layer)))
|
|
)
|
|
|
|
await assertSuccess(provider, [], ConfigProvider.makeRecord(new Set(["empty"])))
|
|
await assertSuccess(provider, ["empty"], undefined)
|
|
})
|
|
|
|
it("preserves empty files when requested", async () => {
|
|
const Fs = FileSystem.layerNoop({
|
|
readFileString(path) {
|
|
return path === "/empty"
|
|
? Effect.succeed("\n")
|
|
: Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readFileString"
|
|
})
|
|
)
|
|
},
|
|
readDirectory() {
|
|
return Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readDirectory"
|
|
})
|
|
)
|
|
}
|
|
})
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDir({ rootPath: "/", preserveEmptyStrings: true }).pipe(
|
|
Effect.provide(Layer.mergeAll(Fs, Path.layer))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, ["empty"], ConfigProvider.makeValue(""))
|
|
})
|
|
|
|
it("orElse falls back when fromDir file is empty", async () => {
|
|
const Fs = FileSystem.layerNoop({
|
|
readFileString(path) {
|
|
return path === "/empty"
|
|
? Effect.succeed("")
|
|
: Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readFileString"
|
|
})
|
|
)
|
|
},
|
|
readDirectory() {
|
|
return Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readDirectory"
|
|
})
|
|
)
|
|
}
|
|
})
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDir({ rootPath: "/" }).pipe(
|
|
Effect.map((dir) => dir.pipe(ConfigProvider.orElse(ConfigProvider.fromUnknown({ empty: "fallback" })))),
|
|
Effect.provide(Layer.mergeAll(Fs, Path.layer))
|
|
)
|
|
)
|
|
|
|
await assertSuccess(provider, ["empty"], ConfigProvider.makeValue("fallback"))
|
|
})
|
|
|
|
it("reads directory entries as a record", async () => {
|
|
const dirs: Record<string, ReadonlyArray<string>> = {
|
|
"/app": ["host", "port"]
|
|
}
|
|
const Fs = FileSystem.layerNoop({
|
|
readFileString() {
|
|
return Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readFileString"
|
|
})
|
|
)
|
|
},
|
|
readDirectory(path) {
|
|
const entries = dirs[path]
|
|
return entries
|
|
? Effect.succeed([...entries])
|
|
: Effect.fail(
|
|
PlatformError.systemError({
|
|
module: "FileSystem",
|
|
_tag: "NotFound",
|
|
method: "readDirectory"
|
|
})
|
|
)
|
|
}
|
|
})
|
|
const provider = await Effect.runPromise(
|
|
ConfigProvider.fromDir({ rootPath: "/" }).pipe(Effect.provide(Layer.mergeAll(Fs, Path.layer)))
|
|
)
|
|
await assertSuccess(provider, ["app"], ConfigProvider.makeRecord(new Set(["host", "port"])))
|
|
})
|
|
|
|
it("layerAdd uses fallback", async () => {
|
|
const result = await Effect.runPromise(
|
|
Effect.gen(function*() {
|
|
const provider = yield* ConfigProvider.ConfigProvider
|
|
const secret = yield* provider.load(["secret"])
|
|
const integer = yield* provider.load(["integer"])
|
|
const fallback = yield* provider.load(["fallback"])
|
|
|
|
return { secret, integer, fallback }
|
|
}).pipe(Effect.provide(AddLayer))
|
|
)
|
|
|
|
deepStrictEqual(result.secret, ConfigProvider.makeValue("shh"))
|
|
deepStrictEqual(result.integer, ConfigProvider.makeValue("123"))
|
|
deepStrictEqual(result.fallback, ConfigProvider.makeValue("value"))
|
|
})
|
|
})
|
|
})
|