Skip to content

Commit

Permalink
feat: Options may specify a validation function (#1398)
Browse files Browse the repository at this point in the history
* Add validate property to declaration option types
* Add test cases
  • Loading branch information
krisztianb committed Nov 27, 2020
1 parent 3f63956 commit 884332b
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 41 deletions.
97 changes: 64 additions & 33 deletions src/lib/utils/options/declaration.ts
Expand Up @@ -171,6 +171,12 @@ export interface StringDeclarationOption extends DeclarationOptionBase {
* An optional hint for the type of input expected, will be displayed in the help output.
*/
hint?: ParameterHint;

/**
* An optional validation function that validates a potential value of this option.
* The function must throw an Error if the validation fails and should do nothing otherwise.
*/
validate?: (value: string) => void;
}

export interface NumberDeclarationOption extends DeclarationOptionBase {
Expand All @@ -190,6 +196,12 @@ export interface NumberDeclarationOption extends DeclarationOptionBase {
* If not specified defaults to 0.
*/
defaultValue?: number;

/**
* An optional validation function that validates a potential value of this option.
* The function must throw an Error if the validation fails and should do nothing otherwise.
*/
validate?: (value: number) => void;
}

export interface BooleanDeclarationOption extends DeclarationOptionBase {
Expand All @@ -208,6 +220,12 @@ export interface ArrayDeclarationOption extends DeclarationOptionBase {
* If not specified defaults to an empty array.
*/
defaultValue?: string[];

/**
* An optional validation function that validates a potential value of this option.
* The function must throw an Error if the validation fails and should do nothing otherwise.
*/
validate?: (value: string[]) => void;
}

export interface MixedDeclarationOption extends DeclarationOptionBase {
Expand All @@ -217,10 +235,17 @@ export interface MixedDeclarationOption extends DeclarationOptionBase {
* If not specified defaults to undefined.
*/
defaultValue?: unknown;

/**
* An optional validation function that validates a potential value of this option.
* The function must throw an Error if the validation fails and should do nothing otherwise.
*/
validate?: (value: unknown) => void;
}

export interface MapDeclarationOption<T> extends DeclarationOptionBase {
type: ParameterType.Map;

/**
* Maps a given value to the option type. The map type may be a TypeScript enum.
* In that case, when generating an error message for a mismatched key, the numeric
Expand Down Expand Up @@ -276,66 +301,72 @@ export function convert<T extends DeclarationOption>(
value: unknown,
option: T
): DeclarationOptionToOptionType<T>;
export function convert<T extends DeclarationOption>(
value: unknown,
option: T
): unknown {
export function convert<T>(value: unknown, option: MapDeclarationOption<T>): T;
export function convert(value: unknown, option: DeclarationOption): unknown {
switch (option.type) {
case undefined:
case ParameterType.String:
return value == null ? "" : String(value);
case ParameterType.String: {
const stringValue = value == null ? "" : String(value);
if (option.validate) {
option.validate(stringValue);
}
return stringValue;
}
case ParameterType.Number: {
const numberOption = option as NumberDeclarationOption;
const numValue = parseInt(String(value), 10) || 0;
if (
!valueIsWithinBounds(
numValue,
numberOption.minValue,
numberOption.maxValue
)
!valueIsWithinBounds(numValue, option.minValue, option.maxValue)
) {
throw new Error(
getBoundsError(
numberOption.name,
numberOption.minValue,
numberOption.maxValue
option.name,
option.minValue,
option.maxValue
)
);
}
if (option.validate) {
option.validate(numValue);
}
return numValue;
}

case ParameterType.Boolean:
return Boolean(value);
case ParameterType.Array:

case ParameterType.Array: {
let strArrValue = new Array<string>();
if (Array.isArray(value)) {
return value.map(String);
strArrValue = value.map(String);
} else if (typeof value === "string") {
return value.split(",");
strArrValue = value.split(",");
}
return [];
if (option.validate) {
option.validate(strArrValue);
}
return strArrValue;
}
case ParameterType.Map: {
const optionMap = option as MapDeclarationOption<unknown>;
const key = String(value).toLowerCase();
if (optionMap.map instanceof Map) {
if (optionMap.map.has(key)) {
return optionMap.map.get(key);
}
if ([...optionMap.map.values()].includes(value)) {
return value;
}
} else {
if (key in optionMap.map) {
return optionMap.map[key];
}
if (Object.values(optionMap.map).includes(value)) {
if (option.map instanceof Map) {
if (option.map.has(key)) {
return option.map.get(key);
} else if ([...option.map.values()].includes(value)) {
return value;
}
} else if (key in option.map) {
return option.map[key];
} else if (Object.values(option.map).includes(value)) {
return value;
}
throw new Error(
optionMap.mapError ?? getMapError(optionMap.map, optionMap.name)
option.mapError ?? getMapError(option.map, option.name)
);
}
case ParameterType.Mixed:
if (option.validate) {
option.validate(value);
}
return value;
}
}
Expand Down
11 changes: 5 additions & 6 deletions src/lib/utils/options/options.ts
Expand Up @@ -3,14 +3,14 @@ import * as _ from "lodash";
import * as ts from "typescript";

import {
convert,
DeclarationOption,
KeyToDeclaration,
ParameterScope,
ParameterType,
convert,
TypeDocOptions,
KeyToDeclaration,
TypeDocAndTSOptions,
TypeDocOptionMap,
TypeDocOptions,
} from "./declaration";
import { Logger } from "../loggers";
import { insertPrioritySorted } from "../array";
Expand Down Expand Up @@ -150,20 +150,19 @@ export class Options {
/**
* Adds an option declaration to the container with extra type checking to ensure that
* the runtime type is consistent with the declared type.
* @param declaration
* @param declaration The option declaration that should be added.
*/
addDeclaration<K extends keyof TypeDocOptions>(
declaration: { name: K } & KeyToDeclaration<K>
): void;

/**
* Adds an option declaration to the container.
* @param declaration
* @param declaration The option declaration that should be added.
*/
addDeclaration(
declaration: NeverIfInternal<Readonly<DeclarationOption>>
): void;

addDeclaration(declaration: Readonly<DeclarationOption>): void {
const names = [declaration.name];
if (declaration.short) {
Expand Down
133 changes: 131 additions & 2 deletions src/test/utils/options/declaration.test.ts
@@ -1,11 +1,14 @@
import { deepStrictEqual as equal, throws } from "assert";
import {
ArrayDeclarationOption,
convert,
DeclarationOption,
ParameterType,
MapDeclarationOption,
MixedDeclarationOption,
NumberDeclarationOption,
ParameterType,
StringDeclarationOption,
} from "../../../lib/utils/options/declaration";
import { deepStrictEqual as equal, throws } from "assert";

describe("Options - Default convert function", () => {
const optionWithType = (type: ParameterType) =>
Expand Down Expand Up @@ -76,6 +79,36 @@ describe("Options - Default convert function", () => {
);
});

it("Generates no error for a number option if the validation function doesn't throw one", () => {
const declaration: NumberDeclarationOption = {
name: "test",
help: "",
type: ParameterType.Number,
validate: (value: number) => {
if (value % 2 !== 0) {
throw new Error("test must be even");
}
},
};
equal(convert(0, declaration), 0);
equal(convert(2, declaration), 2);
equal(convert(4, declaration), 4);
});

it("Generates an error for a number option if the validation function throws one", () => {
const declaration: NumberDeclarationOption = {
name: "test",
help: "",
type: ParameterType.Number,
validate: (value: number) => {
if (value % 2 !== 0) {
throw new Error("test must be even");
}
},
};
throws(() => convert(1, declaration), new Error("test must be even"));
});

it("Converts to strings", () => {
equal(convert("123", optionWithType(ParameterType.String)), "123");
equal(convert(123, optionWithType(ParameterType.String)), "123");
Expand All @@ -84,6 +117,37 @@ describe("Options - Default convert function", () => {
equal(convert(void 0, optionWithType(ParameterType.String)), "");
});

it("Generates no error for a string option if the validation function doesn't throw one", () => {
const declaration: StringDeclarationOption = {
name: "test",
help: "",
type: ParameterType.String,
validate: (value: string) => {
if (value !== value.toUpperCase()) {
throw new Error("test must be upper case");
}
},
};
equal(convert("TOASTY", declaration), "TOASTY");
});

it("Generates an error for a string option if the validation function throws one", () => {
const declaration: StringDeclarationOption = {
name: "test",
help: "",
type: ParameterType.String,
validate: (value: string) => {
if (value !== value.toUpperCase()) {
throw new Error("test must be upper case");
}
},
};
throws(
() => convert("toasty", declaration),
new Error("test must be upper case")
);
});

it("Converts to booleans", () => {
equal(convert("a", optionWithType(ParameterType.Boolean)), true);
equal(convert([1], optionWithType(ParameterType.Boolean)), true);
Expand All @@ -99,6 +163,38 @@ describe("Options - Default convert function", () => {
equal(convert(true, optionWithType(ParameterType.Array)), []);
});

it("Generates no error for an array option if the validation function doesn't throw one", () => {
const declaration: ArrayDeclarationOption = {
name: "test",
help: "",
type: ParameterType.Array,
validate: (value: string[]) => {
if (value.length === 0) {
throw new Error("test must not be empty");
}
},
};
equal(convert(["1"], declaration), ["1"]);
equal(convert(["1", "2"], declaration), ["1", "2"]);
});

it("Generates an error for an array option if the validation function throws one", () => {
const declaration: ArrayDeclarationOption = {
name: "test",
help: "",
type: ParameterType.Array,
validate: (value: string[]) => {
if (value.length === 0) {
throw new Error("test must not be empty");
}
},
};
throws(
() => convert([], declaration),
new Error("test must not be empty")
);
});

it("Converts to mapped types", () => {
const declaration: MapDeclarationOption<number> = {
name: "",
Expand Down Expand Up @@ -185,4 +281,37 @@ describe("Options - Default convert function", () => {
const data = Symbol();
equal(convert(data, optionWithType(ParameterType.Mixed)), data);
});

it("Generates no error for a mixed option if the validation function doesn't throw one", () => {
const declaration: MixedDeclarationOption = {
name: "test",
help: "",
type: ParameterType.Mixed,
defaultValue: "default",
validate: (value: unknown) => {
if (typeof value === "number") {
throw new Error("test must not be a number");
}
},
};
equal(convert("text", declaration), "text");
});

it("Generates an error for a mixed option if the validation function throws one", () => {
const declaration: MixedDeclarationOption = {
name: "test",
help: "",
type: ParameterType.Mixed,
defaultValue: "default",
validate: (value: unknown) => {
if (typeof value === "number") {
throw new Error("test must not be a number");
}
},
};
throws(
() => convert(1, declaration),
new Error("test must not be a number")
);
});
});

0 comments on commit 884332b

Please sign in to comment.