Skip to content

Commit

Permalink
feat: input package and new route system
Browse files Browse the repository at this point in the history
  • Loading branch information
izatop committed Nov 8, 2020
1 parent 5bdf0c7 commit 2914121
Show file tree
Hide file tree
Showing 58 changed files with 1,026 additions and 59 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@typescript-eslint/eslint-plugin": "^4.6.1",
"@typescript-eslint/parser": "^4.6.1",
"cross-env": "^7.0.2",
"eslint": "^7.12.1",
"eslint": "^7.13.0",
"husky": "^4.3.0",
"jest": "^26.6.3",
"lerna": "^3.22.1",
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"dependencies": {
"@typesafeunit/unit": "^0.9.12",
"@typesafeunit/input": "^0.1.0",
"@typesafeunit/util": "^0.9.12",
"path-to-regexp": "^6.2.0"
},
Expand Down
53 changes: 34 additions & 19 deletions packages/app/src/Application.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import {Context, ContextArg, unit, Unit} from "@typesafeunit/unit";
import {assert, isDefined, isFunction, isInstanceOf, isObject, logger, Logger} from "@typesafeunit/util";
import {IRequest, MatchRoute, RouteAction, RouteResponse} from "./interfaces";
import {RouteAbstract, RouteNotFound} from "./Route";
import {IRequest, MatchRoute, RouteResponse} from "./interfaces";
import {IRoute, RouteAbstract, RouteNotFound} from "./Route";

export class Application<U extends Unit<C>, C> {
@logger
public logger!: Logger;

protected readonly unit: U;
protected readonly route: RouteAbstract<RouteAction>[] = [];
protected readonly route: IRoute[] = [];

constructor(u: U) {
this.unit = u;
Expand All @@ -20,7 +20,7 @@ export class Application<U extends Unit<C>, C> {

public static async factory<C extends Context>(
context: ContextArg<C>,
routes: MatchRoute<C, RouteAbstract<RouteAction>>[] = []): Promise<Application<Unit<C>, C>> {
routes: MatchRoute<C, IRoute>[] = []): Promise<Application<Unit<C>, C>> {
const app = new this<Unit<C>, C>(await unit(context));
if (routes.length > 0) {
routes.forEach((route) => app.add(route));
Expand All @@ -29,15 +29,15 @@ export class Application<U extends Unit<C>, C> {
return app;
}

public add<R extends RouteAbstract<RouteAction>>(route: MatchRoute<C, R>): this {
public add<R extends IRoute>(route: MatchRoute<C, R>): this {
this.logger.debug("add", route);
assert(!this.unit.has(route.action), `This route was already added`);
this.unit.add(route.action);
this.route.push(route);
return this;
}

public remove<R extends RouteAbstract<RouteAction>>(route: MatchRoute<C, R>): this {
public remove<R extends RouteAbstract>(route: MatchRoute<C, R>): this {
if (this.unit.has(route.action)) {
this.logger.debug("remove", route);
this.unit.remove(route.action);
Expand Down Expand Up @@ -73,22 +73,27 @@ export class Application<U extends Unit<C>, C> {
this.logger.debug("match", route);

const state = {};
const requestArgs = new Map<string, string>();
const requestArgs = new Map<string, string>(Object.entries(route.match(request.route)));
const context = await this.unit.getContext();
Object.entries(route.match(request.route))
.forEach(([key, value]) => requestArgs.set(key, value));

const routeContext = {request, context, args: requestArgs};
if (isFunction(route.validate)) {
await route.validate(routeContext);
const {payload} = route;

if (isDefined(payload)) {
Object.assign(state, await payload.validate(routeContext));
}

if (isDefined(route.state)) {
if (isFunction(route.state)) {
Object.assign(state, await route.state(routeContext));
} else if (isObject(route.state)) {
for (const [name, factory] of Object.entries(route.state)) {
Reflect.set(state, name, await factory(routeContext));
if (this.isRouteStateStyle(route)) {
if (isFunction(route.validate)) {
await route.validate(routeContext);
}

if (isDefined(route.state)) {
if (isFunction(route.state)) {
Object.assign(state, await route.state(routeContext));
} else if (isObject(route.state)) {
for (const [name, factory] of Object.entries(route.state)) {
Reflect.set(state, name, await factory(routeContext));
}
}
}
}
Expand All @@ -100,7 +105,17 @@ export class Application<U extends Unit<C>, C> {
throw new RouteNotFound(request.route);
}

public getRoutes(): RouteAbstract[] {
public getRoutes(): IRoute[] {
return this.route;
}

/**
* @param route
*
* @deprecated see Route/Route.ts, @typesafeunit/input and InputState.test.ts
* @protected
*/
protected isRouteStateStyle(route: RouteAbstract | IRoute): route is RouteAbstract {
return "state" in route;
}
}
19 changes: 19 additions & 0 deletions packages/app/src/Payload/Payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {FieldSelectType, validate} from "@typesafeunit/input";
import {ActionContext, ActionState} from "@typesafeunit/unit";
import {RouteAction} from "../interfaces";
import {IRouteContext} from "../Route";
import {Resolver} from "./Resolver";

export class Payload<A extends RouteAction> {
public readonly resolver: Resolver<A>;
public readonly type: FieldSelectType<ActionState<A>>;

constructor(type: FieldSelectType<ActionState<A>>, resolver: Resolver<A>) {
this.type = type;
this.resolver = resolver;
}

public async validate(context: IRouteContext<ActionContext<A>>): Promise<ActionState<A>> {
return validate<ActionState<A>>(this.type, await this.resolver.resolve(context));
}
}
37 changes: 37 additions & 0 deletions packages/app/src/Payload/Resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {ActionContext, ActionState} from "@typesafeunit/unit";
import {isFunction, isObject} from "@typesafeunit/util";
import {RouteAction} from "../interfaces";
import {IRouteContext} from "../Route";

type ResolverFn<A extends RouteAction> = (context: IRouteContext<ActionContext<A>>) => ActionState<A> | unknown;
type ResolverType<A extends RouteAction> = ActionState<A> | ResolverFn<A>;

type ResolverList<A extends RouteAction> = {
[K in keyof ActionState<A>]-?: ResolverType<A>;
};

export class Resolver<A extends RouteAction> {
readonly resolvers: ResolverList<A>;

constructor(resolvers: ResolverList<A>) {
this.resolvers = resolvers;
}

public async resolve(context: IRouteContext<ActionContext<A>>): Promise<ActionState<A>> {
const state = {};
const {resolvers} = this;
if (isFunction(resolvers)) {
Object.assign(state, await resolvers(context));
} else if (isObject(resolvers)) {
for (const [name, resolver] of Object.entries(resolvers)) {
if (isFunction(resolver)) {
Reflect.set(state, name, await resolver(context));
} else {
Reflect.set(state, name, resolver);
}
}
}

return state as ActionState<A>;
}
}
2 changes: 2 additions & 0 deletions packages/app/src/Payload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./Resolver";
export * from "./Payload";
35 changes: 35 additions & 0 deletions packages/app/src/Route/Route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {ActionCtor} from "@typesafeunit/unit";
import {ILogable} from "@typesafeunit/util";
import {RouteAction} from "../interfaces";
import {Payload} from "../Payload";
import {IRoute, IRouteMatcher, RouteMatcherFactory, RouteNew, RouteNewArgs} from "./interfaces";

export class Route<A extends RouteAction = RouteAction> implements IRoute<A>, ILogable<{ route: string }> {
public readonly route: string;
public readonly action: ActionCtor<A>;
public readonly payload?: Payload<A>;
readonly #matcher: IRouteMatcher;

constructor(matcher: RouteMatcherFactory, ...[route, action, payload]: RouteNewArgs<A>) {
this.route = route;
this.action = action as ActionCtor<A>;
this.payload = payload as Payload<A>;
this.#matcher = matcher(route);
}

public static create(matcher: RouteMatcherFactory): RouteNew {
return <A extends RouteAction>(...args: RouteNewArgs<A>) => new Route<A>(matcher, ...args);
}

public getLogValue(): { route: string } {
return {route: this.route};
}

public test(route: string): boolean {
return this.#matcher.test(route);
}

public match(route: string): Record<string, string> {
return this.#matcher.match(route);
}
}
5 changes: 3 additions & 2 deletions packages/app/src/Route/RouteAbstract.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {ActionCtor} from "@typesafeunit/unit";
import {ILogable} from "@typesafeunit/util";
import {RouteAction} from "../interfaces";
import {IRouteMatcher, RouteConfig, RouteConfigState, RouteConfigValidate} from "./interfaces";
import {IRoute, IRouteMatcher, RouteConfig, RouteConfigState, RouteConfigValidate} from "./interfaces";

export abstract class RouteAbstract<A extends RouteAction = RouteAction> implements ILogable<{ route: string }> {
export abstract class RouteAbstract<A extends RouteAction = RouteAction>
implements IRoute, ILogable<{ route: string }> {
public readonly action: ActionCtor<A>;
public readonly route: string;
public readonly validate?: RouteConfigValidate<A>;
Expand Down
26 changes: 23 additions & 3 deletions packages/app/src/Route/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import {Action, ActionCtor, Context, IContext, Promisify} from "@typesafeunit/unit";
import {IRequest, RouteAction, RouteResponse} from "../interfaces";
import {Payload} from "../Payload";
import {Route} from "./Route";
import {RouteAbstract} from "./RouteAbstract";

export type RouteFactory<A extends RouteAction> = (action: ActionCtor<A>, config: RouteConfig<A>)
=> RouteAbstract<A>;
export interface IRoute<A extends RouteAction = RouteAction> {
readonly route: string;

readonly action: ActionCtor<A>;

readonly payload?: Payload<A>;

test(route: string): boolean;

match(route: string): Record<string, string>;
}

export type RouteNewArgs<A extends RouteAction> = A extends Action<IContext, infer S>
? S extends null | undefined | never
? [route: string, action: ActionCtor<A>]
: [route: string, action: ActionCtor<A>, payload: Payload<A>]
: never;

export type RouteNew = <A extends RouteAction>(...args: RouteNewArgs<A>) => Route<A>;
export type RouteMatcherFactory = (route: string) => IRouteMatcher;

export type RouteArgs<A extends RouteAction> = [ActionCtor<A>, RouteConfig<A>];

Expand All @@ -30,7 +50,7 @@ export type RouteConfigValidate<A> = A extends Action<infer C, any, RouteRespons

export type RouteConfig<A> = RouteConfigState<A> extends never
? Pick<RouteConfigInner<A>, "route" | "validate">
: RouteConfigInner<A>;
: Pick<RouteConfigInner<A>, "route" | "state" | "validate">;

export interface RouteConfigInner<A> {
readonly route: string;
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/Transport/Request/JSONTransform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import {IRequest} from "../../interfaces";
import {IRequest, IRequestTransform} from "../../interfaces";

export const JSONTransform = async <T>(request: IRequest): Promise<T> => {
request.headers.assert("content-type", ["application/json"]);
const buffer = await request.getBuffer();
return JSON.parse(buffer.toString("utf-8"));
};

export const fromJsonRequest: IRequestTransform<unknown> = Object.assign(
(buffer: Buffer) => JSON.parse(buffer.toString("utf-8")),
{type: "application/json"},
);
12 changes: 11 additions & 1 deletion packages/app/src/Transport/RequestAbstract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Promisify} from "@typesafeunit/unit";
import {assert, ILogable, isFunction} from "@typesafeunit/util";
import {IHeaders, IRequest, RequestTransformType, RouteResponse} from "../interfaces";
import {IHeaders, IRequest, IRequestTransform, RequestTransformType, RouteResponse} from "../interfaces";
import {fromJsonRequest} from "./Request";

export abstract class RequestAbstract implements IRequest, ILogable<{ route: string }> {
public abstract readonly route: string;
Expand Down Expand Up @@ -30,6 +31,15 @@ export abstract class RequestAbstract implements IRequest, ILogable<{ route: str
return transformer.transform(this);
}

public async to<T>(transform: IRequestTransform<T>): Promise<T> {
this.headers.assert("content-type", [transform.type].flat(1));
return transform(await this.getBuffer());
}

public toObject<T = unknown>(): Promise<T> {
return this.to<unknown>(fromJsonRequest) as Promise<T>;
}

public async respond(response: RouteResponse): Promise<void> {
assert(!this.complete, `Response was already sent`);
try {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./Route";
export * from "./Transport";
export * from "./Application";
export * from "./Payload";
export * from "./interfaces";
14 changes: 12 additions & 2 deletions packages/app/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Action, Context, MatchContext, Promisify} from "@typesafeunit/unit";
import {RouteAbstract} from "./Route";
import {IRoute, RouteAbstract} from "./Route";

export type RouteResponse = Error
| { stringify(): string }
Expand All @@ -15,7 +15,7 @@ export type RouteResponse = Error

export type RouteAction = Action<any, any, RouteResponse>;

export type MatchRoute<C extends Context, R> = R extends RouteAbstract<infer A>
export type MatchRoute<C extends Context, R> = R extends IRoute<infer A>
? A extends Action<infer AC, any, RouteResponse>
? MatchContext<C, AC> extends AC
? R
Expand Down Expand Up @@ -49,11 +49,21 @@ export interface IRequestBodyTransform<T> {

export type RequestTransformType<T> = IRequestBodyTransform<T> | ((request: IRequest) => Promise<T>);

export interface IRequestTransform<T> {
type: string | string[];

(buffer: Buffer): T;
}

export interface IRequest {
readonly route: string;
readonly headers: IHeaders;
readonly complete: boolean;

to<T>(transform: IRequestTransform<T>): Promise<T>;

toObject<T = unknown>(): Promise<T>;

getBuffer(): Promise<Buffer>;

createReadableStream(): Promisify<NodeJS.ReadableStream>;
Expand Down
34 changes: 34 additions & 0 deletions packages/app/test/src/InputState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Payload} from "@typesafeunit/app";
import TestInputStateValidationRoute, {
resolver,
type,
} from "@typesafeunit/test/src/actions/TestInputStateValidationRoute";
import {MainContext} from "../../../test/src/context/MainContext";
import {Request} from "../../../test/src/transport/Request";
import {Application} from "../../src";

describe("Route", () => {
const bd = new Date("2020-11-08T12:18:06.051Z").toISOString();
const headers = {"Content-Type": "application/json", "Authorization": "s5sChdDmOPmFLtEEsq4L"};
const request = new Request("/test", headers, JSON.stringify({bd, name: "Peter"}));
const failRequest = new Request("/test", headers, JSON.stringify({}));

test("Payload", async () => {
const payload = new Payload(type, resolver);
const context = {request, args: new Map<string, string>(Object.entries(headers)), context: {}};
await expect(payload.validate(context))
.resolves
.toMatchSnapshot();
});

test("Success", async () => {
const app = await Application.factory(new MainContext(), [TestInputStateValidationRoute]);
await app.handle(request);
expect(request).toMatchSnapshot();
});

test("Fails", async () => {
const app = await Application.factory(new MainContext(), [TestInputStateValidationRoute]);
expect(app.handle(failRequest)).rejects.toMatchSnapshot();
});
});
Loading

0 comments on commit 2914121

Please sign in to comment.