Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial work for file plugin #67

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
w/o ex
AdamTranquilla committed Jan 16, 2025
commit 6868226a62d8fe41459db3f41236e09efb01d8ea
91 changes: 66 additions & 25 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@
"packages/*"
],
"scripts": {
"build": "npm run build --workspace=config-dug --workspace=@config-dug/plugin-aws-secrets-manager",
"build": "npm run build --workspace=config-dug --workspace=@config-dug/plugin-aws-secrets-manager --workspace=@config-dug/plugin-file",
"clean": "rimraf packages/*/build",
"clean:modules": "rimraf node_modules examples/*/node_modules packages/*/node_modules",
"format": "prettier --write \"**/*.{ts,js,cjs,mjs,json,md}\"",
4 changes: 4 additions & 0 deletions packages/plugin-file/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/build
**/coverage
**/node_modules
.eslintrc.cjs
16 changes: 16 additions & 0 deletions packages/plugin-file/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module.exports = {
extends: ['eslint-config-neo/config-base'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: ['./tsconfig.cjs.json', './test/tsconfig.json'],
sourceType: 'module',
},
rules: {
jest: 'off',
'jest/no-commented-out-tests': 'off',
'jest/no-deprecated-functions': 'off',
'jest/valid-expect': 'off',
'jest/valid-expect-in-promise': 'off',
},
};
5 changes: 5 additions & 0 deletions packages/plugin-file/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Plugin AWS Secrets Manager Changelog

## 1.0.0 (TBD)

- Initial release
90 changes: 90 additions & 0 deletions packages/plugin-file/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# File Plugin

A file plugin for Config Dug

This plugin is used to support loading configuration values via the File API: https://api.configcat.com/docs/

# Usage

## Defining the File plugin

```ts
const filePlugin = new FilePlugin({
files: ['config.json'],
});
```

### Parameter Reference

| Parameter | Type | Description |
| :--------------- | :------- | :------------------------------------------------------------------------ |
| `sourceKeyStyle` | `number` | The naming convention used by the plugin source. ex. SCREAMING_SNAKE_CASE |
| `targetedFlags` | `array` | The definition of all targeted flags to be loaded by the plugin. |

## Adding targeted flags to the Config Dug schema

> [!IMPORTANT]
> The File plugin exports a custom type `targetedFileFlagSchema` That can be used to define targeted flags within the schema. This is a custom zod definition that matches function definition returned by this plugin and should be used for all targeted flags.
```ts
const schema = {
value1: {
schema: targetedFileFlagSchema,
description: 'Targeted File config boolean',
sensitive: false,
},
};
```

## Adding the plugin to Config Dug

The File flugin can be added to the plugins array in config dug constructor. Keep in mind the plugin load order dictates which values will be used.

```ts
const configDug = new ConfigDug(schema, {
plugins: [filePlugin],
printConfig: true,
});
```

## Complete example

```ts
const schema = {
value1: {
schema: z.string(),
description: 'Non-targeted File string',
sensitive: false,
},
value2: {
schema: targetedFileFlagSchema,
description: 'Targeted File value',
sensitive: true,
},
};

const filePlugin = new FilePlugin({
sourceKeyStyle: 'SCREAMING_SNAKE_CASE',
targetedFlags: [
{
key: 'value1',
defaultValue: false,
},
{
key: 'value2',
defaultValue: 'test default',
},
],
});

const configDug = new ConfigDug(schema, {
plugins: [filePlugin],
printConfig: true,
});

await configDug.load();
const config = configDug.getConfig();

console.log(config.value1); // returns a string value from File
console.log(await config.value2({ identifier: 'Some Id Value' })); // returns the targeted flag response
```
57 changes: 57 additions & 0 deletions packages/plugin-file/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"name": "@config-dug/plugin-file",
"version": "1.0.0-alpha.0",
"author": "Neo Financial Engineering <engineering@neofinancial.com>",
"description": "A file loading plugin",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"type": "module",
"main": "./build/cjs/index.js",
"module": "./build/esm/index.js",
"types": "./build/types/index.d.ts",
"exports": {
".": {
"types": "./build/types/index.d.ts",
"require": "./build/cjs/index.js",
"import": "./build/esm/index.js",
"default": "./build/cjs/index.js"
},
"./*": {
"types": "./build/types/*.d.ts",
"require": "./build/cjs/*.js",
"import": "./build/esm/*.js",
"default": "./build/cjs/*.js"
},
"./package.json": "./package.json"
},
"files": [
"build",
"CHANGELOG.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc -b tsconfig.cjs.json tsconfig.esm.json tsconfig.types.json",
"watch": "tsc -b tsconfig.cjs.json tsconfig.esm.json tsconfig.types.json --watch",
"clean": "rimraf build",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"globby": "^11.1.0",
"ms": "^2.1.3"
},
"peerDependencies": {
"config-dug": "^2.0.0-alpha.1"
},
"devDependencies": {
"@tsconfig/node18": "^1.0.3",
"@types/debug": "^4.1.7",
"@types/node": "^18.11.18",
"debug": "^4.3.4",
"typescript": "^5.0.4",
"vitest": "^2.1.8"
}
}
115 changes: 115 additions & 0 deletions packages/plugin-file/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {
ConfigDugPlugin,
ConfigDugOptions,
ConfigDugPluginOutput,
BaseConfigDugPlugin,
} from '../../config-dug/src/index';
import createDebug from 'debug';
import globby from 'globby';
import ms from 'ms';

export interface FilePluginOptions {
files: string[];
reloadInterval?: string | number;
}

const debug = createDebug('config-dug:plugin:file');

class FilePlugin extends BaseConfigDugPlugin<FilePluginOptions> implements ConfigDugPlugin {
private configDugOptions: ConfigDugOptions | undefined;
private valueOrigins: Record<string, string[]> = {};

constructor(options: FilePluginOptions) {
super(options);
this.pluginOptions = options;
}

public override initialize = async (configDugOptions: ConfigDugOptions): Promise<void> => {
this.configDugOptions = configDugOptions;
this.initialized = true;
};

public load = async (): Promise<ConfigDugPluginOutput> => {
if (!this.initialized) {
throw new Error('Plugin not initialized');
}

if (this.configDugOptions === undefined) {
throw new Error('ConfigDugOptions not set');
}

this.valueOrigins = {};
let values: Record<string, unknown> = {};
let nextReloadIn: number | undefined;

const paths = await globby(this.pluginOptions.files);

debug('paths', paths);

for (const file of paths) {
if (path.extname(file) === '.js') {
debug('basePath', this.configDugOptions.basePath);
const basePath = this.configDugOptions.basePath;
if (!basePath) {
throw new Error('basePath is not set in configDugOptions');
}
const module = await import(path.join(basePath, file));

if (typeof module === 'object') {
values = { ...values, ...module };
this.recordValueOrigins(module, file);
}
} else if (path.extname(file) === '.json') {
const contents = await fs.readFile(file, 'utf8');

try {
const module = JSON.parse(contents);

if (typeof module === 'object') {
values = { ...values, ...module };
this.recordValueOrigins(module, file);
}
} catch (error) {
console.error('Error parsing JSON file', file, error);
}
}
}

nextReloadIn = this.getNextReloadInterval(this.pluginOptions.reloadInterval);

debug('plugin values', values);
debug('plugin value origins', this.valueOrigins);
debug('plugin reload in', nextReloadIn);

return {
values,
valueOrigins: this.valueOrigins,
nextReloadIn,
};
};

private recordValueOrigins = (values: Record<string, unknown>, origin: string) => {
for (const key of Object.keys(values)) {
if (!this.valueOrigins[key]) {
this.valueOrigins[key] = [];
}
if (!this.valueOrigins[key].includes(origin)) {
this.valueOrigins[key].push(origin);
}
}
};

private getNextReloadInterval(reloadInterval: string | number | undefined): number | undefined {
if (typeof reloadInterval === 'string') {
const reloadIn = ms(reloadInterval);

return reloadIn + Date.now();
} else if (typeof reloadInterval === 'number') {
return reloadInterval + Date.now();
}
}
}

export { FilePlugin };
141 changes: 141 additions & 0 deletions packages/plugin-file/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { FilePlugin } from '../src/index';
import fs from 'node:fs/promises';
import path from 'node:path';
import globby from 'globby';
vi.mock('node:fs/promises');
vi.mock('globby');

const testPluginOptions = {
files: ['config.json', 'config.js'],
};

let plugin: FilePlugin;

beforeEach(() => {
plugin = new FilePlugin(testPluginOptions);
});

describe('FilePlugin', () => {
describe('initialize', () => {
it('should initialize the plugin', async () => {
await plugin.initialize({ basePath: '/base/path' });

expect(plugin['initialized']).toBe(true);
});
});

describe('load', () => {
it('should load JSON files and return values', async () => {
const mockJsonContent = JSON.stringify({ testKey: 'testValue' });
(globby as any).mockResolvedValue(['config.json']);
(fs.readFile as any).mockResolvedValue(mockJsonContent);

await plugin.initialize({ basePath: '/base/path' });

const output = await plugin.load();
expect(output.values).toEqual({ testKey: 'testValue' });
});

it('should load JS files and return values', async () => {
(globby as any).mockResolvedValue(['config.js']);
(path.join as any) = vi.fn().mockReturnValue('/base/path/config.js');
vi.mock('/base/path/config.js', () => ({ testKey: 'testValue' }));

await plugin.initialize({ basePath: '/base/path' });

const output = await plugin.load();
expect(output.values).toEqual({ testKey: 'testValue' });
});

it('should throw an error if load is called before initialize', async () => {
await expect(plugin.load()).rejects.toThrowError('Plugin not initialized');
});

it('should handle JSON files with invalid content', async () => {
(globby as any).mockResolvedValue(['config.json']);
(fs.readFile as any).mockResolvedValue('invalid-json');

await plugin.initialize({ basePath: '/base/path' });

await expect(plugin.load()).resolves.toEqual({
values: {},
valueOrigins: {},
nextReloadIn: undefined,
});
});

it('should handle empty files array', async () => {
plugin = new FilePlugin({ files: [] });
await plugin.initialize({ basePath: '/base/path' });

await expect(plugin.load()).resolves.toEqual({
values: {},
valueOrigins: {},
nextReloadIn: undefined,
});
});

it('should handle empty secrets', async () => {
await plugin.initialize({});
await expect(plugin.load()).resolves.toEqual({
values: {},
valueOrigins: {},
nextReloadIn: undefined,
});
});

it('should reload files based on reloadInterval', async () => {
const reloadInterval = 100;
plugin = new FilePlugin({ files: ['config.json'], reloadInterval });

const mockJsonContent = JSON.stringify({ testKey: 'testValue' });
(globby as any).mockResolvedValueOnce(['config.json']);
(fs.readFile as any).mockResolvedValueOnce(mockJsonContent);

await plugin.initialize({ basePath: '/base/path' });

const output = await plugin.load();

expect(output.values).toEqual({ testKey: 'testValue' });
expect(output.nextReloadIn).toBeGreaterThan(Date.now());
});

it('should reload files based on reloadInterval', async () => {
const reloadInterval = 100;
plugin = new FilePlugin({ files: ['config.json'], reloadInterval });

const mockJsonContent = JSON.stringify({ testKey: 'testValue' });
(globby as any).mockResolvedValueOnce(['config.json']);
(fs.readFile as any).mockResolvedValueOnce(mockJsonContent);
await plugin.initialize({ basePath: '/base/path' });

const output = await plugin.load();

expect(output.values).toEqual({ testKey: 'testValue' });
expect(output.nextReloadIn).toBeGreaterThan(Date.now());
});

it('should reload values correctly', async () => {
const reloadInterval = 100;
const files = ['config.json'];
plugin = new FilePlugin({ files, reloadInterval });

const mockJsonContent1 = JSON.stringify({ testKey: 'initialValue' });
const mockJsonContent2 = JSON.stringify({ testKey: 'reloadedValue' });
(globby as any).mockResolvedValueOnce(files);
(fs.readFile as any).mockResolvedValueOnce(mockJsonContent1);

await plugin.initialize({ basePath: '/base/path' });

let output = await plugin.load();
expect(output.values).toEqual({ testKey: 'initialValue' });

(fs.readFile as any).mockResolvedValueOnce(mockJsonContent2);
output = await plugin.load();
expect(output.values).toEqual({ testKey: 'reloadedValue' });
expect(output.valueOrigins).toEqual({ testKey: ['config.json'] });
expect(output.nextReloadIn).toBeGreaterThan(Date.now());
});
});
});
20 changes: 20 additions & 0 deletions packages/plugin-file/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"noImplicitAny": false,
"types": ["vitest/globals"],
"noEmit": true,
"experimentalDecorators": true,
"strictNullChecks": false,
"strict": true,
"noImplicitOverride": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"sourceMap": true,
"isolatedModules": false,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"downlevelIteration": true
},
"include": ["./**/*", "../vite.config.js"]
}
11 changes: 11 additions & 0 deletions packages/plugin-file/test/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineProject } from 'vitest/config';

export default defineProject({
test: {
globals: true,
environment: 'node',
logHeapUsage: true,
include: ['test/**/*.test.ts'],
maxConcurrency: 16,
},
});
12 changes: 12 additions & 0 deletions packages/plugin-file/tsconfig.cjs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "@tsconfig/node18",
"compilerOptions": {
"module": "CommonJS",
"allowSyntheticDefaultImports": true,
"sourceMap": false,
"declaration": true,
"declarationMap": false,
"outDir": "build/cjs"
},
"include": ["src/**/*"]
}
12 changes: 12 additions & 0 deletions packages/plugin-file/tsconfig.esm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "@tsconfig/node18",
"compilerOptions": {
"module": "ES2020",
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"outDir": "build/esm"
},
"include": ["src/**/*"]
}
10 changes: 10 additions & 0 deletions packages/plugin-file/tsconfig.types.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@tsconfig/node18",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "build/types"
},
"include": ["src/**/*"]
}
19 changes: 19 additions & 0 deletions packages/plugin-file/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config';

// https://vitest.dev/config/#configuration
// usage of swc: https://github.com/vitest-dev/vitest/discussions/1905
export default defineConfig({
test: {
globals: true,
include: ['test/**/*.test.ts'],
environment: 'node',
testTimeout: 10000,
threads: false,
logHeapUsage: true,
reporters: process.environment === 'TEST' ? 'verbose' : 'default',
coverage: {
reporter: ['lcov', 'text'],
reportsDirectory: './coverage',
},
},
});