Skip to content

Commit

Permalink
WIP: Prepare hyrest and hyrest-mobx for array types
Browse files Browse the repository at this point in the history
  • Loading branch information
Prior99 committed Aug 9, 2018
1 parent 6869f5d commit c1550d4
Show file tree
Hide file tree
Showing 21 changed files with 473 additions and 197 deletions.
14 changes: 8 additions & 6 deletions packages/hyrest-express/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import * as HTTP from "http-status-codes";
/**
* A wrapper around a `Route` which also carries the Route's parameter injections.
*/
interface RouteConfiguration {
readonly route: Route;
interface RouteConfiguration<TController> {
readonly route: Route<TController>;
readonly queryParameters: QueryParameter[];
readonly bodyParameters: BodyParameter[];
readonly urlParameters: UrlParameter[];
Expand All @@ -46,7 +46,7 @@ interface RouteConfiguration {
*
* @return An array of all routes present on all supplied controllers.
*/
function listRoutes(controllerObjects: any[]): RouteConfiguration[] {
function listRoutes(controllerObjects: any[]): RouteConfiguration<any>[] {
return controllerObjects.reduce((result, controllerObject) => {
// Fetch all routes from this particular controller and put the parameter injections next to tehm.
const routes = getRoutes(controllerObject).map(route => ({
Expand Down Expand Up @@ -102,7 +102,7 @@ export function hyrest<TContext>(...controllerObjects: any[]): HyrestMiddleware<
// Get the actual `Controller` instances for each @controller decorated object.
// Throws an error if an instance of a class not decorated with @controller has been passed.
const controllers = controllerObjects.map(controllerObject => {
const controller: Controller = Reflect.getMetadata("api:controller", controllerObject.constructor);
const controller: Controller<any> = Reflect.getMetadata("api:controller", controllerObject.constructor);
if (!controller) {
const name = controllerObject.constructor.name;
throw new Error(`Added an object to the Hyrest middleware which is not a @controller. Check ${name}.`);
Expand All @@ -111,7 +111,7 @@ export function hyrest<TContext>(...controllerObjects: any[]): HyrestMiddleware<
});

// Get a flat list of all routes present on all controllers.
const routes: RouteConfiguration[] = listRoutes(controllerObjects);
const routes: RouteConfiguration<any>[] = listRoutes(controllerObjects);

const router: HyrestMiddleware<TContext> = Router() as any;
routes.forEach(({ route, queryParameters, bodyParameters, urlParameters, contextParameters, controllerObject }) => {
Expand Down Expand Up @@ -238,7 +238,9 @@ export function hyrest<TContext>(...controllerObjects: any[]): HyrestMiddleware<
case "HEAD": router.head(route.url, handler); break;
case "OPTIONS": router.options(route.url, handler); break;
case "TRACE": router.trace(route.url, handler); break;
default: throw new Error(`Unknown HTTP method ${route.method}. Take a look at ${route.property}.`);
default: throw new Error(
`Unknown HTTP method ${route.method}. Take a look at ${route.property as string}.`,
);
}
});
router.context = (factory: ContextFactory<TContext> | TContext) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/hyrest-mobx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
"README.md"
],
"devDependencies": {
"@types/invariant": "^2.2.29",
"typedoc": "^0.11.1",
"typescript": "^3.0.1"
},
"dependencies": {
"bind-decorator": "^1.0.11",
"hyrest": "^0.8.0",
"invariant": "^2.2.4",
"mobx": "^5.0.3",
"reflect-metadata": "^0.1.12"
}
Expand Down
48 changes: 48 additions & 0 deletions packages/hyrest-mobx/src/base-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ValidationStatus } from "./validation-status";

export interface BaseField<TModel> {
/**
* The validation status for the current value.
* Always held in synchronization with the value.
*/
status: ValidationStatus;

/**
* The actual, unwrapped value for this field.
* When called on a structure, this will create a real model.
*/
value: TModel;

/**
* Whether the current value is valid.
* Syntactic sugar for `status === ValidationStatus.VALID`.
*/
valid: boolean;

/**
* Whether the current value is invalid.
* Syntactic sugar for `status === ValidationStatus.INVALID`.
*/
invalid: boolean;

/**
* Whether the validation for the current value is still in progress.
* Syntactic sugar for `status === ValidationStatus.IN_PROGRESS`.
*/
inProgress: boolean;

/**
* Whether the value has never been set before.
* Syntactic sugar for `status === ValidationStatus.UNTOUCHED`.
*/
untouched: boolean;

/**
* Can be called to update the value, for example when the user typed
* something in a related field.
*
* When called on a structure, this will update all underlying fields
* recursively.
*/
update(newValue: TModel): Promise<void>;
}
File renamed without changes.
9 changes: 5 additions & 4 deletions packages/hyrest-mobx/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
universal,
} from "hyrest";
import { ValidationStatus } from "./validation-status";
import { ContextFactory } from "./types";
import { Field } from "./field";
import { ContextFactory } from "./context-factory";
import { createField } from "./field";

export interface FieldsMeta {
fieldProperties: {
Expand All @@ -30,14 +30,15 @@ export function getFieldsMeta(target: Object): FieldsMeta {

export function hasFields<TContext>(
contextFactory: ContextFactory<TContext>,
fieldType: Function = Field,
fieldFactory: typeof createField = createField,
): ClassDecorator {
const decorator = function <T extends Function>(target: T): T {
const constructor = function OverloadedConstructor(this: any, ...args: any[]): any {
const instance = new (target as any)(...args);
const { fieldProperties } = getFieldsMeta(target);
fieldProperties.forEach(({ property, modelType }) => {
(instance as any)[property] = new Field(modelType, contextFactory);
const propertyMeta = universal.propertiesForClass(modelType).find(meta => meta.property === property);
(instance as any)[property] = fieldFactory(propertyMeta, contextFactory);
});
return instance;
};
Expand Down
67 changes: 67 additions & 0 deletions packages/hyrest-mobx/src/field-array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { bind } from "bind-decorator";
import {
PropertyMeta,
getPropertyValidation,
Constructable,
populate,
ValidationOptions,
universal,
} from "hyrest";
import { observable, computed, action } from "mobx";
import { ValidationStatus } from "./validation-status";
import { ContextFactory } from "./context-factory";
import { BaseField } from "./base-field";

export class FieldArray<TModel, TContext> extends Array<TModel> implements ReadonlyArray<TModel>, BaseField<TModel[]> {
/**
* The validation status for this field.
*/
@observable public status = ValidationStatus.UNTOUCHED;

/**
* A validator if the developer used a `@is` decorator on the class.
*/
private validation?: ValidationOptions<TModel, TContext>;

/**
* The context factory provided by the `@hasFields` decorator, creating a context handed
* down to the validator for the `.validateCtx` method.
*/
private contextFactory: ContextFactory<TContext>;

/**
* The runtime-accessible type of the model. Specified using `@specify` or using reflection.
*/
private modelType: Constructable<TModel>;

constructor (
modelType: Constructable<TModel>,
contextFactory: ContextFactory<TContext>,
validation: ValidationOptions<TModel, TContext>,
) {
super();
this.contextFactory = contextFactory;
this.validation = validation;
this.modelType = modelType;
}

// public get array(): FieldArray<TModel[0], TContext> {
// if (this._array) { return this._array; }
// if (!Array.isArray(this.model) && this.model !== null && typeof this.model !== "undefined") {
// throw new Error("Can't create a field array from a non-Array type.");
// }
// this._array = new FieldArray(this.model as any as any[]);
// }
public get value(): TModel[] {
throw new Error("TODO");
}

@computed public get valid(): boolean { return this.status === ValidationStatus.VALID; }
@computed public get invalid(): boolean { return this.status === ValidationStatus.INVALID; }
@computed public get inProgress(): boolean { return this.status === ValidationStatus.IN_PROGRESS; }
@computed public get untouched(): boolean { return this.status === ValidationStatus.UNTOUCHED; }

@bind @action public async update(newValue: TModel[]) {
throw new Error("TODO");
}
}
166 changes: 166 additions & 0 deletions packages/hyrest-mobx/src/field-simple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { bind } from "bind-decorator";
import * as invariant from "invariant";
import {
PropertyMeta,
getPropertyValidation,
Constructable,
populate,
ValidationOptions,
universal,
} from "hyrest";
import { observable, computed, action } from "mobx";
import { ValidationStatus } from "./validation-status";
import { ContextFactory } from "./context-factory";
import { BaseField } from "./base-field";
import { Fields } from "./fields";
import { createField } from "./field";
import { FieldArray } from "./field-array";

export class FieldSimple<TModel, TContext = any> implements BaseField<TModel> {
/**
* The validation status for this field.
*/
@observable public status = ValidationStatus.UNTOUCHED;

/**
* Nested fields of this field.
*/
public nested: Fields<TModel, TContext>;

/**
* The `nested` property on this class is by default initialized using `undefined` for
* every property to allow for lazy initialization of deeply nested structures.
* This is an internal map for keeping the real values.
*/
private _nested: Fields<TModel, TContext>;

/**
* The runtime-accessible type of the model. Specified using `@specify` or using reflection.
*/
private modelType: Constructable<TModel>;

/**
* A validator if the developer used a `@is` decorator on the class.
*/
private validation?: ValidationOptions<TModel, TContext>;

/**
* The actual value for this field if it doesn't have sub-fields.
*/
@observable private model: TModel = undefined;

/**
* Property metadata gathered from the `universal` scope about the `modelType`.
* Cached and initialized from the constructor.
*/
private properties: PropertyMeta[];

/**
* The context factory provided by the `@hasFields` decorator, creating a context handed
* down to the validator for the `.validateCtx` method.
*/
private contextFactory: ContextFactory<TContext>;

constructor(
modelType: Constructable<TModel>,
contextFactory: ContextFactory<TContext>,
validation?: ValidationOptions<TModel, TContext>,
) {
// Copy all arguments that should be stored.
this.contextFactory = contextFactory;
this.validation = validation;
this.modelType = modelType;
// Gather and cache all property metadata from the universal scope.
this.properties = universal.propertiesForClass(this.modelType);
// Initialize the map of real, underlying values for each nested field to contain an `undefined`
// value for each key. This is necessary for Mobx, as the addition of new keys later on will not trigger
// the observable.
this._nested = this.properties.reduce((result, { target, property, expectedType }) => {
// Need to initialize the whole fields map with `undefined` to help frameworks such as MobX.
result[property as keyof TModel] = undefined;
return result;
}, {} as Fields<TModel, TContext>);
// Create the outfacing `nested` property: Create a getter for each property which lazily initializes
// and caches the real value in `_nested`.
this.nested = this.properties.reduce((result, propertyMeta) => {
// Create a getter on the `fields` property which will lazily initialize all `Field`s.
Object.defineProperty(result, propertyMeta.property, {
get: () => {
const key = propertyMeta.property as keyof TModel;
if (this._nested[key] !== undefined) { return this._nested[key]; }
// The cast to `any` are neccessary as Typescript cannot deal with this
// kind of types. See https://github.com/Microsoft/TypeScript/issues/22628 (for example).
this._nested[key] =
createField<TModel[keyof TModel], TContext>(propertyMeta, this.contextFactory) as any;
},
enumerable: true,
});
return result;
}, {} as Fields<TModel, TContext>);
}

/**
* Determines whether this is a managed property.
* If the developer defined any validation decorators (`@is`) on this class, it is managed and therefore
* for example a new `Field` instance needs to be created. Also, no value will be managed in this instance.
*/
private get isManaged() {
return this.properties.length !== 0;
}

public get value(): TModel {
if (this.isManaged) {
const obj = Object.keys(this._nested).reduce((result: TModel, key) => {
const modelKey = key as keyof TModel;
const field = this._nested[modelKey];
if (!field) { return result; }
invariant(
field instanceof FieldSimple || field instanceof FieldArray,
"Found an invalid wrapped field value.",
);
if (field instanceof FieldSimple) {
result[modelKey] = field.value;
} else {
result[modelKey] = field.value as TModel[keyof TModel];
}
return result;
}, {});
return populate(this.modelType, obj);
}
return this.model;
}

@computed public get valid(): boolean { return this.status === ValidationStatus.VALID; }
@computed public get invalid(): boolean { return this.status === ValidationStatus.INVALID; }
@computed public get inProgress(): boolean { return this.status === ValidationStatus.IN_PROGRESS; }
@computed public get untouched(): boolean { return this.status === ValidationStatus.UNTOUCHED; }

@bind @action public async update(newValue: TModel) {
if (this.isManaged) {
await Promise.all(Object.keys(newValue).map((key) => {
const modelKey = key as keyof TModel;
const field = this._nested[modelKey];
invariant(
field instanceof FieldSimple || field instanceof FieldArray,
"Found an invalid wrapped field value.",
);
if (field instanceof FieldSimple) {
return field.update(newValue[modelKey]);
}
if (field instanceof FieldArray) {
return field.update(newValue[modelKey] as TModel[keyof TModel] & any[]);
}
}));
return;
}
this.model = newValue;
if (this.validation.fullValidator) {
this.status = ValidationStatus.IN_PROGRESS;
const processed = await this.validation.fullValidator(
newValue,
{ context: this.contextFactory() },
);
this.status = processed.hasErrors ? ValidationStatus.INVALID : ValidationStatus.VALID;
}
}
}

0 comments on commit c1550d4

Please sign in to comment.