From 609b9b63cb3c94902bbd93209070a6fa34baf7e8 Mon Sep 17 00:00:00 2001 From: Max Metral Date: Tue, 17 Oct 2023 09:28:26 -0400 Subject: [PATCH] fix(shortstop): bundle more shortstop handlers, add tests, release --- .github/workflows/publish.yml | 2 +- __tests__/yaml/bad.yaml | 3 + __tests__/yaml/good.yaml | 2 + package.json | 3 + src/handlers.ts | 6 +- src/shortstop/envHandler.spec.ts | 72 ++++++++++++++++++ src/shortstop/envHandler.ts | 30 ++++++++ src/shortstop/fileHandlers.spec.ts | 62 ++++++++++++++++ src/shortstop/fileHandlers.ts | 115 +++++++++++++++++++++++++++++ src/shortstop/index.ts | 3 + src/shortstop/path.spec.ts | 31 -------- src/shortstop/path.ts | 16 ---- src/shortstop/textHandlers.ts | 5 ++ yarn.lock | 12 ++- 14 files changed, 310 insertions(+), 52 deletions(-) create mode 100644 __tests__/yaml/bad.yaml create mode 100644 __tests__/yaml/good.yaml create mode 100644 src/shortstop/envHandler.spec.ts create mode 100644 src/shortstop/envHandler.ts create mode 100644 src/shortstop/fileHandlers.spec.ts create mode 100644 src/shortstop/fileHandlers.ts delete mode 100644 src/shortstop/path.spec.ts delete mode 100644 src/shortstop/path.ts create mode 100644 src/shortstop/textHandlers.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c066276..9bc5b66 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,7 +3,7 @@ name: Node.js Package on: push: branches: - - main-notyet + - main jobs: build: diff --git a/__tests__/yaml/bad.yaml b/__tests__/yaml/bad.yaml new file mode 100644 index 0000000..7d90fe8 --- /dev/null +++ b/__tests__/yaml/bad.yaml @@ -0,0 +1,3 @@ +root: + this-yaml: is-bad + - why: because \ No newline at end of file diff --git a/__tests__/yaml/good.yaml b/__tests__/yaml/good.yaml new file mode 100644 index 0000000..10982ad --- /dev/null +++ b/__tests__/yaml/good.yaml @@ -0,0 +1,2 @@ +root: + key: value \ No newline at end of file diff --git a/package.json b/package.json index 5d65913..f0403fb 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } } diff --git a/src/handlers.ts b/src/handlers.ts index e9924f5..ed540c7 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -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'; @@ -40,10 +40,10 @@ export async function resolveImport( basedir: string, ): Promise { 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); }); diff --git a/src/shortstop/envHandler.spec.ts b/src/shortstop/envHandler.spec.ts new file mode 100644 index 0000000..f51dc48 --- /dev/null +++ b/src/shortstop/envHandler.spec.ts @@ -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; + + 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); + }); +}); diff --git a/src/shortstop/envHandler.ts b/src/shortstop/envHandler.ts new file mode 100644 index 0000000..c18017d --- /dev/null +++ b/src/shortstop/envHandler.ts @@ -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; + }; +} diff --git a/src/shortstop/fileHandlers.spec.ts b/src/shortstop/fileHandlers.spec.ts new file mode 100644 index 0000000..dce356a --- /dev/null +++ b/src/shortstop/fileHandlers.spec.ts @@ -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')); + }); +}); diff --git a/src/shortstop/fileHandlers.ts b/src/shortstop/fileHandlers.ts new file mode 100644 index 0000000..2db6d9b --- /dev/null +++ b/src/shortstop/fileHandlers.ts @@ -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[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 { + 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())); + }; +} diff --git a/src/shortstop/index.ts b/src/shortstop/index.ts index 1e03cce..f54e687 100644 --- a/src/shortstop/index.ts +++ b/src/shortstop/index.ts @@ -1 +1,4 @@ export * from './create'; +export * from './fileHandlers'; +export * from './textHandlers'; +export * from './envHandler'; diff --git a/src/shortstop/path.spec.ts b/src/shortstop/path.spec.ts deleted file mode 100644 index baa629e..0000000 --- a/src/shortstop/path.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import Path from 'path'; - -import { expect, test } from 'vitest'; - -import { path } from './path'; - -test('path shortstop handler', () => { - expect(typeof path).toBe('function'); - expect(path.length).toBe(1); - - const handler = path(); - // 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 = path(__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); -}); diff --git a/src/shortstop/path.ts b/src/shortstop/path.ts deleted file mode 100644 index cc6327d..0000000 --- a/src/shortstop/path.ts +++ /dev/null @@ -1,16 +0,0 @@ -import Path from 'path'; - -import caller from 'caller'; - -export function path(basedir?: string) { - const basedirOrDefault = basedir || Path.dirname(caller()); - return (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); - }; -} diff --git a/src/shortstop/textHandlers.ts b/src/shortstop/textHandlers.ts new file mode 100644 index 0000000..d15a823 --- /dev/null +++ b/src/shortstop/textHandlers.ts @@ -0,0 +1,5 @@ +export function base64Handler() { + return function base64Handler(value: string) { + return Buffer.from(value, 'base64'); + }; +} diff --git a/yarn.lock b/yarn.lock index 9fab5ab..e889d8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -431,6 +431,7 @@ __metadata: "@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 @@ -442,6 +443,8 @@ __metadata: eslint-config-prettier: ^9.0.0 eslint-import-resolver-typescript: ^3.6.1 eslint-plugin-import: ^2.28.1 + glob: ^10.3.10 + js-yaml: ^4.1.0 minimist: ^1.2.8 typescript: ^5.2.2 vitest: ^0.34.6 @@ -513,6 +516,13 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:^4.0.7": + version: 4.0.7 + resolution: "@types/js-yaml@npm:4.0.7" + checksum: e02eca876f3f19dbba62a26a18e59ec5df4e29300b99808fb0b4ca980cbfbb60e4a2c7d75d521728f5b2a3b92a0da5b9343497b2acf621b8ea019d48b40bbd78 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.12": version: 7.0.13 resolution: "@types/json-schema@npm:7.0.13" @@ -2097,7 +2107,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2": +"glob@npm:^10.2.2, glob@npm:^10.3.10": version: 10.3.10 resolution: "glob@npm:10.3.10" dependencies: