Skip to content

Commit

Permalink
feat(runner/node): type safe arguments
Browse files Browse the repository at this point in the history
  • Loading branch information
jenspots committed Jul 7, 2024
1 parent 13d58c3 commit e2e2406
Show file tree
Hide file tree
Showing 12 changed files with 599 additions and 112 deletions.
3 changes: 2 additions & 1 deletion runners/nodejs/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"quotes": ["error", "double"],
"semi": ["error", "always"],
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/ban-types": "off"
"@typescript-eslint/ban-types": "off",
"default-case": "error"
}
}
8 changes: 4 additions & 4 deletions runners/nodejs/package-lock.json

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

3 changes: 2 additions & 1 deletion runners/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"scripts": {
"run": "ts-node src/runtime/index.ts",
"build": "tsc && tsc-alias",
"prepare": "ts-patch install -s",
"test": "vitest run --coverage --coverage.include src",
"format": "eslint --fix . && prettier --write ."
},
Expand Down Expand Up @@ -36,7 +37,7 @@
"prettier": "^3.3.2",
"ts-proto": "^1.180.0",
"tsc-alias": "^1.8.10",
"typescript": "^5.4.5",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.6.0"
Expand Down
32 changes: 24 additions & 8 deletions runners/nodejs/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,38 @@
export class RunnerError extends Error {
constructor(message: string) {
private constructor(message: string) {
super(message);
this.name = "JVMRunnerError";
this.name = "RunnerError";
}

static missingArgument(key: string): RunnerError {
return new RunnerError(`Missing argument: ${key}`);
static inconsistency(message: string | null = null): never {
let msg = "An error occurred while parsing incoming data.";
if (message) {
msg += "\n" + message;
}
throw new RunnerError(msg);
}

static missingImplementation(): RunnerError {
return new RunnerError("Not implemented");
static missingArgument(key: string): never {
throw new RunnerError(`Missing argument: ${key}`);
}

static incorrectType(key: string, type: string): never {
throw new RunnerError(`Incorrect type '${type}' for argument: ${key}`);
}

static nonExhaustiveSwitch(): never {
throw new RunnerError("Non-exhaustive switch statement");
}

static missingImplementation(): never {
throw new RunnerError("Not implemented");
}

static channelError(): RunnerError {
return new RunnerError("Channel error");
}

static unexpectedBehaviour(): RunnerError {
return new RunnerError("Unexpected behaviour");
static unexpectedBehaviour(): never {
throw new RunnerError("Unexpected behaviour");
}
}
23 changes: 4 additions & 19 deletions runners/nodejs/src/interfaces/processor.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,17 @@
import { RunnerError } from "../error";
import { Arguments } from "../runtime/arguments";

export class Processor {
export abstract class Processor {
/* Retrieve a processor definition by its resource name. */
private args: Map<string, unknown>;
protected args: Arguments;

/* Parse the incoming arguments. */
constructor(args: Map<string, unknown>) {
constructor(args: Arguments) {
this.args = args;
}

/* The actual implementation of the processor must be overridden here. */
public exec(): void {
throw RunnerError.missingImplementation();
}

/* Retrieve an argument. */
public getArgument<T>(key: string): T {
const result = this.args.get(key);

if (!result) {
throw RunnerError.missingArgument(key);
}

return result as T;
}

/* Retrieve an optional argument. */
public getOptionalArgument<T>(key: string): T | null {
return (this.args.get(key) ?? null) as T | null;
}
}
214 changes: 214 additions & 0 deletions runners/nodejs/src/runtime/arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Warning: this file contains magic! We use advanced TypeScript features to
* provide a type-safe way to handle arguments in the runtime. We heavily rely
* on conditional types for this, as well as literal types, since those do not
* get erased during compilation.
*
* Unfortunately, this means that adding new types to the runner requires a bit
* of work, and the corresponding types should be added in multiple places here.
*
* Preferably, we should find a way to use reified types, such as in Kotlin, so
* the `Options` object is not needed. However, there doesn't seem to be a good
* solution for that.
*/

import { Writer } from "../interfaces/writer";
import { Reader } from "../interfaces/reader";
import { RunnerError } from "../error";

/**
* Argument types supported by RDF-Connect. These are enumerated as strings, in
* order to support usage at runtime through literals.
*/
type Type =
| "boolean"
| "byte"
| "date"
| "double"
| "float"
| "int"
| "long"
| "string"
| "writer"
| "reader"
| "map";

/**
* Map a type to its native Node.js type.
*/
type GetType<T extends Type> = T extends "boolean"
? boolean
: T extends "byte"
? number
: T extends "date"
? Date
: T extends "double"
? number
: T extends "float"
? number
: T extends "int"
? number
: T extends "long"
? number
: T extends "string"
? string
: T extends "writer"
? Writer
: T extends "reader"
? Reader
: T extends "map"
? Arguments
: never;

/**
* Check if a given value conforms to a given type.
* @param value The value to check.
* @param type The abstract type to check against.
*/
function conforms(value: unknown, type: Type): boolean {
switch (type) {
case "boolean":
return typeof value === "boolean";
case "byte":
return typeof value === "number";
case "date":
return value instanceof Date;
case "double":
return typeof value === "number";
case "float":
return typeof value === "number";
case "int":
return typeof value === "number";
case "long":
return typeof value === "number";
case "string":
return typeof value === "string";
case "writer":
return value instanceof Writer;
case "reader":
return value instanceof Reader;
case "map":
return value instanceof Arguments;
default:
RunnerError.nonExhaustiveSwitch();
}
}

/**
* Literal type which indicates if the requested type is a singleton or a list.
*/
type List = "true" | "false";

/**
* Literal type which indicate if the requested type is a nullable or not.
*/
type Nullable = "true" | "false";

/**
* Given a type `T`, return either `T` or `T[]` based on a `List` value.
*/
type GetList<T, L extends List | undefined> = L extends undefined
? T
: L extends "true"
? T[]
: T;

/**
* Given a type `T`, return either `T` or `T?` depending on a `Nullable` value.
*/
type GetNullable<T, N extends Nullable | undefined> = N extends undefined
? T
: N extends "true"
? T | null
: T;

/**
* Describes the return type of a returned argument function.
*/
type Options<
T extends Type,
L extends List | undefined,
N extends Nullable | undefined,
> = {
type: T;
list: L | undefined;
nullable: N | undefined;
};

/**
* Parse an `Options` type into a single concrete type.
*/
type Returns<
T extends Type,
L extends List | undefined,
N extends Nullable | undefined,
> = GetNullable<GetList<GetType<T>, L>, N>;

/**
* Wrapper class for processor arguments, which holds a string-to-any map and
* provides a runtime-safe getter function.
*/
export class Arguments {
// The actual arguments, parsed by the runner beforehand.
private readonly args: Map<string, unknown[]>;

constructor(args: Map<string, unknown[]>) {
this.args = args;

// Map all instances of a map into an `Arguments` object.
for (const [key, values] of this.args) {
const newValues = values.map((value) => {
if (value instanceof Map) {
return new Arguments(value);
} else {
return value;
}
});

this.args.set(key, newValues);
}
}

/**
* Retrieve an argument in a type-safe manner using the provided options.
* @param name The name of the argument.
* @param options The options to use for parsing the argument regarding the
* type, count, and presence.
*/
get<
T extends Type,
L extends List | undefined,
N extends Nullable | undefined,
>(name: string, options: Options<T, L, N>): Returns<T, L, N> {
const values = this.args.get(name);

// If no value is found, handle accordingly.
if (!values) {
if (options.nullable === "true") {
return null as Returns<T, L, N>;
} else {
RunnerError.missingArgument(name);
}
}

// Cast the value to the correct type.
values.forEach((value) => {
if (!conforms(value, options.type)) {
RunnerError.incorrectType(name, options.type);
}
});

// If the value is a list, return it as such.
if (options.list === "true") {
return values as Returns<T, L, N>;
}

// Check if there is only one value present.
if (values.length != 1) {
RunnerError.inconsistency();
}

// Return the value.
return values[0] as Returns<T, L, N>;
}
}
1 change: 0 additions & 1 deletion runners/nodejs/src/runtime/constructor.ts

This file was deleted.

Loading

0 comments on commit e2e2406

Please sign in to comment.