Skip to content

Commit

Permalink
fix(shortstop): bundle more shortstop handlers, add tests, release
Browse files Browse the repository at this point in the history
  • Loading branch information
djMax committed Oct 17, 2023
1 parent 725376b commit 609b9b6
Show file tree
Hide file tree
Showing 14 changed files with 310 additions and 52 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Node.js Package
on:
push:
branches:
- main-notyet
- main

jobs:
build:
Expand Down
3 changes: 3 additions & 0 deletions __tests__/yaml/bad.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
root:
this-yaml: is-bad
- why: because
2 changes: 2 additions & 0 deletions __tests__/yaml/good.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
root:
key: value
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/caller": "^1.0.0",
"@types/js-yaml": "^4.0.7",
"@types/minimist": "^1.2.3",
"@types/node": "^20.8.6",
"@typescript-eslint/eslint-plugin": "^6.8.0",
Expand All @@ -67,6 +68,8 @@
"dependencies": {
"caller": "^1.1.0",
"comment-json": "^4.2.3",
"glob": "^10.3.10",
"js-yaml": "^4.1.0",
"minimist": "^1.2.8"
}
}
6 changes: 3 additions & 3 deletions src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createShortstopHandlers } from './shortstop';
import { path } from './shortstop/path';
import { pathHandler } from './shortstop/fileHandlers';
import { ConfitOptions, IntermediateConfigValue } from './types';
import { loadJsonc } from './common';

Expand Down Expand Up @@ -40,10 +40,10 @@ export async function resolveImport(
basedir: string,
): Promise<IntermediateConfigValue> {
const shorty = createShortstopHandlers();
const pathHandler = path(basedir);
const resolver = pathHandler(basedir);

shorty.use('import', async (file: string) => {
const resolved = pathHandler(file);
const resolved = resolver(file);
const json = await loadJsonc(resolved);
return shorty.resolve(json);
});
Expand Down
72 changes: 72 additions & 0 deletions src/shortstop/envHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterAll, beforeEach, describe, expect, test } from 'vitest';

import { envHandler } from './envHandler';

describe('env', () => {
const originalEnv = process.env;
let handler: ReturnType<typeof envHandler>;

beforeEach(() => {
process.env = { ...process.env, SAMPLE: '8000' };
handler = envHandler();
});

afterAll(() => {
process.env = originalEnv;
});

test('should validate handler is a function with length 1', () => {
expect(typeof handler).toBe('function');
expect(handler.length).toBe(1);
});

test('should validate raw env values', () => {
expect(handler('SAMPLE')).toBe(process.env.SAMPLE);
});

test('should validate env values as numbers', () => {
expect(handler('SAMPLE|d')).toBe(8000);
});

test('should validate NaN env values', () => {
process.env.SAMPLE = '';
expect(isNaN(handler('SAMPLE|d') as number)).toBe(true);

process.env.SAMPLE = 'hello';
expect(isNaN(handler('SAMPLE|d') as number)).toBe(true);
});

test('should validate boolean env values', () => {
process.env.SAMPLE = '8000';
expect(handler('SAMPLE|b')).toBe(true);

process.env.SAMPLE = 'true';
expect(handler('SAMPLE|b')).toBe(true);

process.env.SAMPLE = 'false';
expect(handler('SAMPLE|b')).toBe(false);

process.env.SAMPLE = '0';
expect(handler('SAMPLE|b')).toBe(false);

delete process.env.SAMPLE;
expect(handler('SAMPLE|b')).toBe(false);
});

test('should validate boolean inverse env values', () => {
process.env.SAMPLE = '8000';
expect(handler('SAMPLE|!b')).toBe(false);

process.env.SAMPLE = 'true';
expect(handler('SAMPLE|!b')).toBe(false);

process.env.SAMPLE = 'false';
expect(handler('SAMPLE|!b')).toBe(true);

process.env.SAMPLE = '0';
expect(handler('SAMPLE|!b')).toBe(true);

delete process.env.SAMPLE;
expect(handler('SAMPLE|!b')).toBe(true);
});
});
30 changes: 30 additions & 0 deletions src/shortstop/envHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export function envHandler() {
const filters: {
[key: string]: (value?: string) => number | boolean;
} = {
'|d': (value?: string) => {
return parseInt(value || '', 10);
},
'|b': (value?: string) => {
return value !== '' && value !== 'false' && value !== '0' && value !== undefined;
},
'|!b': (value?: string) => {
return value === '' || value === 'false' || value === '0' || value === undefined;
},
};

return function envHandler(value: string): string | number | boolean | undefined {
let result: string | number | boolean | undefined = process.env[value];

Object.entries(filters).some(([key, fn]) => {
if (value.endsWith(key)) {
const sliced = value.slice(0, -key.length);
result = fn(process.env[sliced]);
return true;
}
return false;
});

return result;
};
}
62 changes: 62 additions & 0 deletions src/shortstop/fileHandlers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Path from 'path';

import { describe, expect, test } from 'vitest';

import { globHandler, pathHandler, yamlHandler } from './fileHandlers';

describe('file related shortstop handlers', () => {
test('path shortstop handler', () => {
expect(typeof pathHandler).toBe('function');
expect(pathHandler.length).toBe(1);

const handler = pathHandler();
// Default dirname
expect(typeof handler).toBe('function');
expect(handler.length).toBe(1);

// Absolute path
expect(handler(__filename)).toBe(__filename);

// Relative Path
expect(handler(Path.basename(__filename))).toBe(__filename);

const specHandler = pathHandler(__dirname);
expect(typeof specHandler).toBe('function');
expect(specHandler.length).toBe(1);

// Absolute path
expect(specHandler(__filename)).toBe(__filename);

// Relative Path
expect(specHandler(Path.basename(__filename))).toBe(__filename);
});

test('yaml shortstop handler', async () => {
const handler = yamlHandler(Path.join(__dirname, '..', '..', '__tests__', 'yaml'));

expect(() => handler('good.yaml')).not.toThrow();
expect(() => handler('bad.yaml')).rejects.toThrow();
expect(handler('good.yaml')).resolves.toMatchInlineSnapshot(`
{
"root": {
"key": "value",
},
}
`);
});

test('glob shortstop handler', async () => {
const basedir = Path.join(__dirname, '..', '..', '__tests__', 'yaml');
const handler = globHandler(basedir);
expect(typeof handler).toBe('function');
expect(handler.length).toBe(1);

let matches = await handler('**/*.js');
expect(matches?.length).toBe(0);
matches = await handler('**/*.yaml');
expect(matches?.length).toBe(2);
matches = await handler('bad*');
expect(matches?.length).toBe(1);
expect(matches[0]).toBe(Path.join(basedir, 'bad.yaml'));
});
});
115 changes: 115 additions & 0 deletions src/shortstop/fileHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import fs from 'fs/promises';
import Path from 'path';

import { glob, type GlobOptions } from 'glob';
import yaml from 'js-yaml';
import caller from 'caller';

(function expandModulePaths() {
// If this module is deployed outside the app's node_modules, it wouldn't be
// able to resolve other modules deployed under app while evaluating this shortstops.
// Adding app's node_modules folder to the paths will help handle this case.
const paths = module.paths || [];
const appNodeModules = Path.resolve(process.cwd(), 'node_modules');
if (paths.indexOf(appNodeModules) < 0) {
// Assuming Module._nodeModulePaths creates a new module.paths object for each module.
paths.push(appNodeModules);
}
})();

/**
* Return an absolute path for the given value.
*/
export function pathHandler(basedir?: string) {
const basedirOrDefault = basedir || Path.dirname(caller());
return function pathHandler(value: string) {
if (Path.resolve(value) === value) {
// Absolute path already, so just return it.
return value;
}
const components = value.split('/');
components.unshift(basedirOrDefault);
return Path.resolve(...components);
};
}

type ReadOptions = Parameters<typeof fs.readFile>[1];

/**
* Return the contents of a file.
*/
export function fileHandler(basedir?: string | ReadOptions, options?: ReadOptions) {
const finalBasedir = typeof basedir === 'string' ? basedir : undefined;
const finalOptions = typeof basedir === 'object' ? basedir : options;

const pathhandler = pathHandler(finalBasedir);
return async function fileHandler(value: string) {
return fs.readFile(
pathhandler(value),
finalOptions || {
encoding: null,
flag: 'r',
},
);
};
}

/**
* Call require() on a module and return the loaded module
*/
export function requireHandler(basedir?: string): ReturnType<typeof require> {
const resolvePath = pathHandler(basedir);
return function requireHandler(value: string) {
let module = value;
// @see http://nodejs.org/api/modules.html#modules_file_modules
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) {
// NOTE: Technically, paths with a leading '/' don't need to be resolved, but
// leaving for consistency.
module = resolvePath(module);
}

return require(module);
};
}

/**
* Load a YAML file and return the parsed content.
*/
export function yamlHandler(basedir?: string, options: yaml.LoadOptions = {}) {
const resolver = pathHandler(basedir);

return async function yamlParser(value: string) {
const filename = resolver(value);

const content = await fs.readFile(filename, 'utf8');
return yaml.load(content, { ...options, filename });
};
}

export function execHandler(basedir?: string) {
const resolver = requireHandler(basedir);

return function execHandler(value: string) {
const tuple = value.split('#');
const module = resolver(tuple[0]);
const method = module[tuple[1]] || module;

if (typeof method !== 'function') {
throw new Error(`exec: unable to find method ${tuple[1] || 'default'} on module ${tuple[0]}`);
}

return method();
};
}

export function globHandler(optionsOrCwd?: GlobOptions | string) {
const options = typeof optionsOrCwd === 'string' ? { cwd: optionsOrCwd } : (optionsOrCwd || {});
options.cwd = options.cwd || Path.dirname(caller());

const resolvePath = pathHandler(options.cwd.toString());

return async function globHandler(value: string) {
const matches = await glob(value, options);
return matches.map((relativePath) => resolvePath(relativePath.toString()));
};
}
3 changes: 3 additions & 0 deletions src/shortstop/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from './create';
export * from './fileHandlers';
export * from './textHandlers';
export * from './envHandler';
31 changes: 0 additions & 31 deletions src/shortstop/path.spec.ts

This file was deleted.

16 changes: 0 additions & 16 deletions src/shortstop/path.ts

This file was deleted.

5 changes: 5 additions & 0 deletions src/shortstop/textHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function base64Handler() {
return function base64Handler(value: string) {
return Buffer.from(value, 'base64');
};
}
Loading

0 comments on commit 609b9b6

Please sign in to comment.