Skip to content

Commit

Permalink
Security updates and refactors
Browse files Browse the repository at this point in the history
  • Loading branch information
eegli committed Apr 15, 2022
1 parent 5ea2bd9 commit 90872e3
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 141 deletions.
64 changes: 28 additions & 36 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ _Like [Joi](https://joi.dev/) and [Yargs](https://yargs.js.org/) had a baby but

**How it works**

- The package **exports a single parser factory function** from which a type-aware parser can be created. The parser accepts either an object literal or array of strings (usually, `process.argv.slice(2)`)
- The package **exports a single parser factory function** that creates a type-aware parser based on default values. The parser accepts either an object literal or array of strings (usually, `process.argv.slice(2)`)

- The parser checks the input and returns the base with updated matching property values
- The parser checks the input and returns the defaults with updated matching property values

- Additionally, a `help()` function is returned from the factory that can be used to print all available options, sorted by `required`. This is most useful for CLI apps

Expand All @@ -41,30 +41,31 @@ npm i @eegli/tinyparse
```ts
import { createParser } from '@eegli/tinyparse';

const defaultConfig = {
clientId: '',
outputDirectory: '',
};

const { parse, help } = createParser(defaultConfig, [
{
name: 'clientId', // Name of the property
required: true, // Fail if not present
description: 'The client id', // For the help printer
},
const { help, parse } = createParser(
// Default values
{
name: 'outputDirectory',
shortFlag: '-o', // Short flag alias
clientId: '', // Expect a string
outputDirectory: 'data', // Expect a string
},
]);

await parse(process.argv.slice(2)); // Process args

// Or

await parse({
clientId: 'abc', // Object literal
});
// Options per key
[
{
name: 'clientId', // Name of the property
required: true, // Fail if not present
description: 'The client id', // For the help printer
},
{
name: 'outputDirectory', // Name of the property
shortFlag: '-o', // Short flag alias
},
]
);

// Parse user input...
const parsed1 = await parse({ clientId: '123' });

// ...or process args
const parsed2 = await parse(process.argv.slice(2));

// A helper command to print all available options
help('CLI Usage Example');
Expand All @@ -77,7 +78,7 @@ help('CLI Usage Example');
Optional
-o, --outputDirectory <outputDirectory> [string]
`;
```

Expand All @@ -86,8 +87,6 @@ help('CLI Usage Example');
The object that is passed to the factory needs to specify the **exact types** that are desired for the parsed arguments. Its **exact values** will be used as a fallback/default.

```ts
import { createParser } from '@eegli/tinyparse';

const defaultConfig = {
name: 'defaultName', // string
age: 0, // number
Expand All @@ -96,7 +95,6 @@ const defaultConfig = {

const { parse } = createParser(defaultConfig);

// Resolves to a full user configuration
const parsed = await parse({
name: 'eric',
hasDog: false,
Expand All @@ -109,15 +107,13 @@ expect(parsed).toStrictEqual({
});
```

### Required options
### Parsing required options

The factory accepts an optional array of option objects for each config key. If a required argument is not present in the user input, a `ValidationError` is thrown.
The factory accepts an optional array of option objects for each config key. If a required key is not present in the user input, a `ValidationError` is thrown.

This works for object literals as well as string array arguments.

```ts
import { createParser } from '@eegli/tinyparse';

const defaultConfig = {
accessToken: '',
};
Expand All @@ -138,8 +134,6 @@ await parse();
Invalid types are also rejected.

```ts
import { createParser } from '@eegli/tinyparse';

const defaultConfig = {
accessToken: '',
};
Expand Down Expand Up @@ -173,8 +167,6 @@ Tinyparse expects that **every** CLI argument is specified with a long or short
Optionalls, **short flags** can be specified for each argument. Short flags are expected to start with "`-`".

```ts
import { createParser } from '@eegli/tinyparse';

const defaultConfig = {
numberOfPets: 0,
hasDog: true,
Expand Down
53 changes: 23 additions & 30 deletions src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { ValidationError } from './error';
import { displayHelp } from './help';
import { ObjectValues, Options, PartialNullable } from './types';
import { argvTransformer, getOptionByKey } from './utils';
import { ObjectValues, Options } from './types';
import { argvTransformer, isSameType } from './utils';

const requiredSym = Symbol('isRequired');

export function createParser<C extends Record<string, ObjectValues>>(
baseConfig: C,
defaultValues: C,
options?: Options<keyof C>
) {
return {
parse: function (args: PartialNullable<C> | string[] = []): Promise<C> {
parse: function (args: Partial<C> | string[] = []): Promise<C> {
return new Promise((resolve) => {
const requiredArgs = options?.filter((opt) => opt.required) || [];

const config = new Map<string, ObjectValues | symbol>(
Object.entries(baseConfig)
Object.entries(defaultValues)
);

// Set required arguments to null - they will need to be
// defined from the input
// For each required argument, replace its value temporarily
// with a symbol
requiredArgs.forEach((r) => {
config.set(r.name, requiredSym);
});
Expand All @@ -30,34 +30,27 @@ export function createParser<C extends Record<string, ObjectValues>>(
return acc;
}, {} as Record<string, ObjectValues>);

args = argvTransformer(args, shortFlags) as PartialNullable<C>;
args = argvTransformer(args, shortFlags) as Partial<C>;
}

Object.entries(args).forEach(([arg, argVal]) => {
if (config.has(arg)) {
// Get the type of the argument from the default config -
// not via the config map, since they are set to null
// if they are required
const isValidNull =
getOptionByKey(arg, options)?.allowNull && argVal === null;
const isSameType = typeof baseConfig[arg] === typeof argVal;

if (isValidNull || isSameType) {
config.set(arg, argVal);
} else {
throw new ValidationError(
`Invalid type for "${arg}". Expected ${typeof baseConfig[
arg
]}, got ${typeof argVal}`
);
}
if (!config.has(arg)) {
return;
}
// The received type must corresponds to the original type
if (isSameType(typeof defaultValues[arg], typeof argVal)) {
config.set(arg, argVal);
} else {
throw new ValidationError(
`Invalid type for "${arg}". Expected ${typeof defaultValues[
arg
]}, got ${typeof argVal}`
);
}
console.warn(`Ignoring unknown argument "${arg}"`);
});

// Check if all required arguments have been defined by the
// input

// Check if all required arguments have been defined or if the
// temporary value is still there
requiredArgs.forEach((arg) => {
if (config.get(arg.name) === requiredSym) {
throw new ValidationError(`"${arg.name}" is required`);
Expand All @@ -68,7 +61,7 @@ export function createParser<C extends Record<string, ObjectValues>>(
});
},
help: function (title?: string) {
return displayHelp(baseConfig, options || [], title);
return displayHelp(defaultValues, options || [], title);
},
};
}
7 changes: 1 addition & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@ export type OptionsObject<K> = {
required?: boolean;
description?: string;
shortFlag?: `-${string}`;
allowNull?: boolean;
};

export type PartialNullable<T> = {
[P in keyof T]?: T[P] | null;
};

export type Options<K = string> = OptionsObject<K extends string ? K : never>[];

export type ObjectValues = string | number | boolean | null;
export type ObjectValues = string | number | boolean;
11 changes: 5 additions & 6 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { ObjectValues, OptionsObject } from './types';
import { ObjectValues } from './types';

export function getOptionByKey<T extends OptionsObject<string>>(
key: string,
options: T[] = []
): T | undefined {
return options.find((opt) => opt.name === key);
const allowedTypes = new Set(['string', 'number', 'boolean']);

export function isSameType(type: string, reference: string): boolean {
return allowedTypes.has(type) && type === reference;
}

export function argvTransformer(
Expand Down
39 changes: 24 additions & 15 deletions test/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,30 @@ jest.spyOn(global.console, 'warn').mockImplementation(jest.fn());

describe('Readme examples', () => {
test('general usage', async () => {
const defaultConfig = {
clientId: '',
outputDirectory: '',
};

const { help } = createParser(defaultConfig, [
{
name: 'clientId', // Name of the property
required: true, // Fail if not present
description: 'The client id', // For the help printer
},
const { help, parse } = createParser(
// Default values
{
name: 'outputDirectory',
shortFlag: '-o', // Short flag alias
clientId: '', // Expect a string
outputDirectory: 'data', // Expect a string
},
]);
// Options per key
[
{
name: 'clientId', // Name of the property
required: true, // Fail if not present
description: 'The client id', // For the help printer
},
{
name: 'outputDirectory', // Name of the property
shortFlag: '-o', // Short flag alias
},
]
);

expect(await parse({ clientId: '123' })).toEqual({
clientId: '123',
outputDirectory: 'data',
});

expect(help('CLI Usage Example')).toMatchInlineSnapshot(`
"CLI Usage Example
Expand All @@ -41,7 +49,6 @@ describe('Readme examples', () => {

const { parse } = createParser(defaultConfig);

// Resolves to a full user configuration
const parsed = await parse({
name: 'eric',
hasDog: false,
Expand All @@ -53,6 +60,7 @@ describe('Readme examples', () => {
hasDog: false,
});
});

test('example, required args', async () => {
const defaultConfig = {
accessToken: '',
Expand Down Expand Up @@ -88,6 +96,7 @@ describe('Readme examples', () => {
);
}
});

test('example, process argv', async () => {
const defaultConfig = {
numberOfPets: 0,
Expand Down
Loading

0 comments on commit 90872e3

Please sign in to comment.