diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..421d00d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,63 @@ +export type Config = { + baseURL: string + apiKey: string | undefined + limit: number + lang: string | undefined + tbs: string | undefined +} + +const DEFAULT_BASE_URL = "http://localhost:3000" +const DEFAULT_LIMIT = 8 +const TBS_VALUES = ["qdr:h", "qdr:d", "qdr:w", "qdr:m", "qdr:y"] + +// Blank strings count as unset so an empty env var does not shadow the default. +function str(value: unknown, name: string, secret = false): string | undefined { + if (value === undefined || value === null) return undefined + if (typeof value !== "string") { + throw new Error( + `fastCRW: option ${name} must be a string${secret ? "" : `, got ${JSON.stringify(value)}`}`, + ) + } + return value.trim() || undefined +} + +function baseURL(value: string): string { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error(`fastCRW: invalid baseURL ${JSON.stringify(value)} (expected an http(s) URL)`) + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`fastCRW: invalid baseURL ${JSON.stringify(value)} (expected an http(s) URL)`) + } + return value.replace(/\/+$/, "") +} + +function limit(value: unknown): number { + const n = typeof value === "string" && value.trim() ? Number(value) : value + if (typeof n !== "number" || !Number.isFinite(n)) return DEFAULT_LIMIT + return Math.min(20, Math.max(1, Math.trunc(n))) +} + +export function resolveConfig( + options: Record | undefined, + env: Record, +): Config { + const opts = options ?? {} + const tbs = str(opts.tbs, "tbs") + if (tbs !== undefined && !TBS_VALUES.includes(tbs)) { + throw new Error( + `fastCRW: invalid tbs ${JSON.stringify(tbs)} (expected one of ${TBS_VALUES.join(", ")})`, + ) + } + return { + baseURL: baseURL( + str(opts.baseURL, "baseURL") ?? str(env.CRW_API_URL, "CRW_API_URL") ?? DEFAULT_BASE_URL, + ), + apiKey: str(opts.apiKey, "apiKey", true) ?? str(env.CRW_API_KEY, "CRW_API_KEY", true), + limit: limit(opts.limit), + lang: str(opts.lang, "lang"), + tbs, + } +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..b32e75c --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { resolveConfig } from "../src/config.ts" + +test("defaults", () => { + assert.deepEqual(resolveConfig({}, {}), { + baseURL: "http://localhost:3000", + apiKey: undefined, + limit: 8, + lang: undefined, + tbs: undefined, + }) +}) + +test("undefined options use defaults", () => { + assert.equal(resolveConfig(undefined, {}).baseURL, "http://localhost:3000") +}) + +test("baseURL precedence: option > env > default", () => { + const env = { CRW_API_URL: "http://env:1" } + assert.equal(resolveConfig({ baseURL: "http://opt:2" }, env).baseURL, "http://opt:2") + assert.equal(resolveConfig({}, env).baseURL, "http://env:1") +}) + +test("baseURL is trimmed and loses trailing slashes", () => { + assert.equal( + resolveConfig({ baseURL: " http://crw.home:3000// " }, {}).baseURL, + "http://crw.home:3000", + ) +}) + +test("blank baseURL option falls through to env", () => { + assert.equal( + resolveConfig({ baseURL: " " }, { CRW_API_URL: "http://env:1" }).baseURL, + "http://env:1", + ) +}) + +test("invalid baseURL throws and names option and value", () => { + for (const bad of ["not a url", "ftp://host", 42]) { + assert.throws(() => resolveConfig({ baseURL: bad }, {}), /baseURL/) + } + assert.throws(() => resolveConfig({}, { CRW_API_URL: "nope" }), /baseURL.*nope/) +}) + +test("apiKey precedence: option > env; unset when absent or blank", () => { + assert.equal(resolveConfig({ apiKey: "a" }, { CRW_API_KEY: "b" }).apiKey, "a") + assert.equal(resolveConfig({}, { CRW_API_KEY: "b" }).apiKey, "b") + assert.equal(resolveConfig({}, { CRW_API_KEY: "" }).apiKey, undefined) + assert.equal(resolveConfig({ apiKey: " " }, {}).apiKey, undefined) +}) + +test("non-string apiKey throws without echoing the value", () => { + assert.throws( + () => resolveConfig({ apiKey: 12345 }, {}), + (err: Error) => /apiKey/.test(err.message) && !err.message.includes("12345"), + ) +}) + +test("limit: clamps to 1..20, invalid falls back to 8, never throws", () => { + assert.equal(resolveConfig({ limit: 5 }, {}).limit, 5) + assert.equal(resolveConfig({ limit: 99 }, {}).limit, 20) + assert.equal(resolveConfig({ limit: 0 }, {}).limit, 1) + assert.equal(resolveConfig({ limit: -3 }, {}).limit, 1) + assert.equal(resolveConfig({ limit: "12" }, {}).limit, 12) + assert.equal(resolveConfig({ limit: 3.9 }, {}).limit, 3) + for (const bad of ["abc", NaN, null, {}, [], true]) { + assert.equal(resolveConfig({ limit: bad }, {}).limit, 8) + } +}) + +test("lang: string passes through, blank is unset, non-string throws", () => { + assert.equal(resolveConfig({ lang: "de" }, {}).lang, "de") + assert.equal(resolveConfig({ lang: "" }, {}).lang, undefined) + assert.throws(() => resolveConfig({ lang: 1 }, {}), /lang/) +}) + +test("tbs: allowed values pass, others throw", () => { + for (const ok of ["qdr:h", "qdr:d", "qdr:w", "qdr:m", "qdr:y"]) { + assert.equal(resolveConfig({ tbs: ok }, {}).tbs, ok) + } + assert.equal(resolveConfig({ tbs: "" }, {}).tbs, undefined) + assert.throws(() => resolveConfig({ tbs: "qdr:x" }, {}), /tbs.*qdr:x/) + assert.throws(() => resolveConfig({ tbs: 1 }, {}), /tbs/) +})