Skip to content

Commit

Permalink
feat: add TTL time string support and validation for store schemas
Browse files Browse the repository at this point in the history
  • Loading branch information
kuoruan committed Jan 10, 2025
1 parent 357b282 commit 9d7abfe
Show file tree
Hide file tree
Showing 7 changed files with 216 additions and 14 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"global-agent": "^3.0.0",
"ioredis": "^5.4.2",
"minimist": "^1.2.8",
"ms": "^2.1.3",
"node-persist": "^4.0.3",
"open": "^10.1.0",
"openid-client": "^5.7.1",
Expand All @@ -72,6 +73,7 @@
"@types/global-agent": "^2.1.3",
"@types/lodash": "^4.17.14",
"@types/minimist": "^1.2.5",
"@types/ms": "^0.7.34",
"@types/node-persist": "^3.1.8",
"@verdaccio/types": "^10.8.0",
"core-js": "^3.40.0",
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 16 additions & 8 deletions src/server/config/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ProviderType } from "@/server/plugin/AuthProvider";
import { type FileConfig, type InMemoryConfig, type RedisConfig, StoreType } from "@/server/store/Store";

import { FileConfigSchema, InMemoryConfigSchema, RedisConfigSchema, RedisStoreConfigHolder } from "./Store";
import { getEnvironmentValue, getStoreFilePath, handleValidationError } from "./utils";
import { getEnvironmentValue, getStoreFilePath, getTTLValue, handleValidationError } from "./utils";

export interface PackageAccess extends IncorrectPackageAccess {
unpublish?: string[];
Expand Down Expand Up @@ -229,11 +229,13 @@ export default class ParsedPluginConfig implements ConfigHolder {
case StoreType.InMemory: {
const storeConfig = this.getConfigValue<InMemoryConfig | undefined>(configKey, InMemoryConfigSchema.optional());

return storeConfig;
if (storeConfig === undefined) return;

return { ...storeConfig, ttl: getTTLValue(storeConfig.ttl) } satisfies InMemoryConfig;
}

case StoreType.Redis: {
const storeConfig = this.getConfigValue<Record<string, unknown> | string | undefined>(
const storeConfig = this.getConfigValue<RedisConfig | string | undefined>(
configKey,
mixed().test({
name: "is-redis-config-or-redis-url",
Expand Down Expand Up @@ -261,10 +263,12 @@ export default class ParsedPluginConfig implements ConfigHolder {

const configHolder = new RedisStoreConfigHolder(storeConfig, configKey);

const username = configHolder.username;
const password = configHolder.password;

return { ...storeConfig, username, password } satisfies RedisConfig;
return {
...storeConfig,
username: configHolder.username,
password: configHolder.password,
ttl: getTTLValue(storeConfig.ttl),
} satisfies RedisConfig;
}

case StoreType.File: {
Expand All @@ -291,7 +295,11 @@ export default class ParsedPluginConfig implements ConfigHolder {
return getStoreFilePath(configPath, config);
}

return { ...config, dir: getStoreFilePath(configPath, config.dir) } satisfies FileConfig;
return {
...config,
dir: getStoreFilePath(configPath, config.dir),
ttl: getTTLValue(config.ttl),
} satisfies FileConfig;
}

default: {
Expand Down
18 changes: 13 additions & 5 deletions src/server/config/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@ import { type FileConfig, type InMemoryConfig, type RedisConfig, StoreType } fro
import { getEnvironmentValue, handleValidationError } from "./utils";

const portSchema = number().min(1).max(65_535);
const ttlSchema = number().min(1000); // 1 second
const ttlSchema = mixed().test({
name: "is-time-string-or-integer",
message: "must be a time string or integer",
test: (value) => {
return (
value === undefined || (typeof value === "string" && value !== "") || (typeof value === "number" && value > 1000) // 1 second
);
},
});

export const InMemoryConfigSchema = object<InMemoryConfig>({
ttl: ttlSchema.optional(),
ttl: ttlSchema,

max: number().min(1).optional(),
});
Expand All @@ -26,7 +34,7 @@ export const RedisConfigSchema = object<RedisConfig>({
password: string().optional(),
port: portSchema.optional(),

ttl: ttlSchema.optional(),
ttl: ttlSchema,

nodes: array()
.of(
Expand All @@ -46,15 +54,15 @@ export const RedisConfigSchema = object<RedisConfig>({
});

export const FileConfigSchema = object<FileConfig>({
ttl: ttlSchema.optional(),
ttl: ttlSchema,

dir: string().required(),
expiredInterval: number().min(1).optional(),
});

abstract class StoreConfig<T> {
constructor(
private config: Record<string, unknown>,
private config: T,
private configKey: string,
) {}

Expand Down
23 changes: 23 additions & 0 deletions src/server/config/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import path from "node:path";
import process from "node:process";

import ms from "ms";

import { pluginKey } from "@/constants";
import logger from "@/server/logger";

Expand Down Expand Up @@ -42,6 +44,27 @@ export function handleValidationError(error: any, ...keyPaths: string[]): never
process.exit(1);
}

/**
* Transform a time string or number into a ms number.
*
* @param ttl - The time to live value.
* @returns The time to live value in ms.
*/
export function getTTLValue(ttl?: number | string): number | undefined {
if (typeof ttl === "string") {
return ms(ttl);
}

return ttl;
}

/**
* Get the absolute path of a store file.
*
* @param configPath - The path to the config file.
* @param storePath - The path to the store files.
* @returns The absolute path of the store file.
*/
export function getStoreFilePath(configPath: string, storePath: string): string {
return path.isAbsolute(storePath) ? storePath : path.normalize(path.join(path.dirname(configPath), storePath));
}
134 changes: 134 additions & 0 deletions tests/server/config/Store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { FileConfigSchema, InMemoryConfigSchema, RedisConfigSchema } from "@/server/config/Store";

describe("InMemoryConfigSchema", () => {
it("should validate valid config", () => {
const validConfigs = [
{ ttl: "1h" },
{ ttl: "1d" },
{ ttl: 3_600_000 }, // 1 hour in ms
{ max: 1000 },
{ ttl: "2h", max: 500 },
{}, // empty config is valid too
];

for (const config of validConfigs) {
expect(InMemoryConfigSchema.isValidSync(config)).toBeTruthy();
}
});

it("should reject invalid ttl values", () => {
const invalidConfigs = [
{ ttl: "" },
{ ttl: 500 }, // less than 1 second
{ ttl: true },
{ ttl: {} },
{ ttl: [] },
];

for (const config of invalidConfigs) {
expect(InMemoryConfigSchema.isValidSync(config)).toBeFalsy();
}
});

it("should reject invalid max values", () => {
const invalidConfigs = [{ max: 0 }, { max: -1 }, { max: true }, { max: {} }];

for (const config of invalidConfigs) {
expect(InMemoryConfigSchema.isValidSync(config)).toBeFalsy();
}
});
});

describe("RedisConfigSchema", () => {
it("should validate valid config", () => {
const validConfigs = [
{ username: "user", password: "pass" },
{ port: 6379 },
{ nodes: ["localhost:6379"] },
{ nodes: [6379] },
{ nodes: [{ host: "localhost", port: 6379 }] },
{ ttl: "1h" },
{ ttl: 3_600_000 },
{}, // empty config is valid too
];

for (const config of validConfigs) {
expect(RedisConfigSchema.isValidSync(config)).toBeTruthy();
}
});

it("should reject invalid port values", () => {
const invalidConfigs = [{ port: 0 }, { port: -1 }, { port: 65_536 }, { port: true }];

for (const config of invalidConfigs) {
expect(RedisConfigSchema.isValidSync(config)).toBeFalsy();
}
});

it("should reject invalid nodes values", () => {
const invalidConfigs = [{ nodes: [null] }, { nodes: [true] }];

for (const config of invalidConfigs) {
expect(RedisConfigSchema.isValidSync(config)).toBeFalsy();
}
});

it("should reject invalid ttl values", () => {
const invalidConfigs = [{ ttl: "" }, { ttl: 500 }, { ttl: true }, { ttl: {} }, { ttl: [] }];

for (const config of invalidConfigs) {
expect(RedisConfigSchema.isValidSync(config)).toBeFalsy();
}
});
});

describe("FileConfigSchema", () => {
it("should validate valid config", () => {
const validConfigs = [
{ dir: "/tmp/cache" },
{ dir: "./cache", ttl: "1h" },
{ dir: "/data", ttl: 3_600_000 },
{ dir: "cache", expiredInterval: 1000 },
{ dir: "/cache", ttl: "2h", expiredInterval: 5000 },
];

for (const config of validConfigs) {
expect(FileConfigSchema.isValidSync(config)).toBeTruthy();
}
});

it("should reject configs without dir", () => {
const invalidConfigs = [{}, { ttl: "1h" }, { expiredInterval: 1000 }];

for (const config of invalidConfigs) {
expect(FileConfigSchema.isValidSync(config)).toBeFalsy();
}
});

it("should reject invalid ttl values", () => {
const invalidConfigs = [
{ dir: "cache", ttl: "" },
{ dir: "cache", ttl: 500 },
{ dir: "cache", ttl: true },
{ dir: "cache", ttl: {} },
{ dir: "cache", ttl: [] },
];

for (const config of invalidConfigs) {
expect(FileConfigSchema.isValidSync(config)).toBeFalsy();
}
});

it("should reject invalid expiredInterval values", () => {
const invalidConfigs = [
{ dir: "cache", expiredInterval: 0 },
{ dir: "cache", expiredInterval: -1 },
{ dir: "cache", expiredInterval: true },
{ dir: "cache", expiredInterval: "1h" },
];

for (const config of invalidConfigs) {
expect(FileConfigSchema.isValidSync(config)).toBeFalsy();
}
});
});
22 changes: 21 additions & 1 deletion tests/server/config/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from "node:path";

import { getEnvironmentValue, getStoreFilePath } from "@/server/config/utils";
import { getEnvironmentValue, getStoreFilePath, getTTLValue } from "@/server/config/utils";

describe("getEnvironmentValue", () => {
const OLD_ENV = process.env;
Expand Down Expand Up @@ -75,3 +75,23 @@ describe("getStoreFilePath", () => {
expect(getStoreFilePath(configPath, relativePath)).toBe(expected);
});
});

describe("getTTLValue", () => {
it("should return undefined when input is undefined", () => {
expect(getTTLValue()).toBeUndefined();
});

it("should return same number when input is number", () => {
expect(getTTLValue(1000)).toBe(1000);
});

it("should parse string values using ms", () => {
expect(getTTLValue("1s")).toBe(1000);
expect(getTTLValue("1m")).toBe(60_000);
expect(getTTLValue("1h")).toBe(3_600_000);
});

it("should return undefined when input is invalid string", () => {
expect(getTTLValue("invalid")).toBeUndefined();
});
});

0 comments on commit 9d7abfe

Please sign in to comment.