From a27618d5a3586894a65ad2594804a2322cc391a8 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 28 Jul 2020 09:11:48 -0500 Subject: [PATCH] Start routing; rehydratable interface; add verbose logging --- app/bundle/daton_units.ts | 1 + app/configs/server.config.ts | 2 + app/units.ts | 11 +- lib/src/http/Request.ts | 16 ++- lib/src/http/kernel/Kernel.ts | 2 + .../http/kernel/module/MountActivatedRoute.ts | 53 +++++++++ .../DehydratedStateResponseFactory.ts | 16 +++ lib/src/http/response/JSONResponseFactory.ts | 15 +++ lib/src/http/response/ResponseFactory.ts | 6 + .../http/response/StringResponseFactory.ts | 15 +++ lib/src/http/routing/ActivatedRoute.ts | 14 +++ lib/src/http/routing/ComplexRoute.ts | 29 +++++ lib/src/http/routing/Route.ts | 41 +++++++ lib/src/http/routing/SimpleRoute.ts | 11 ++ lib/src/http/type/HTTPRequest.ts | 2 + lib/src/support/Rehydratable.ts | 16 +++ lib/src/unit/Canonical.ts | 12 +- lib/src/unit/HttpKernel.ts | 23 ++-- lib/src/unit/HttpServer.ts | 3 - lib/src/unit/Routes.ts | 2 + lib/src/unit/Routing.ts | 105 ++++++++++++++++++ orm/src/model/Model.ts | 18 ++- 22 files changed, 396 insertions(+), 17 deletions(-) create mode 100644 lib/src/http/kernel/module/MountActivatedRoute.ts create mode 100644 lib/src/http/response/DehydratedStateResponseFactory.ts create mode 100644 lib/src/http/response/JSONResponseFactory.ts create mode 100644 lib/src/http/response/ResponseFactory.ts create mode 100644 lib/src/http/response/StringResponseFactory.ts create mode 100644 lib/src/http/routing/ActivatedRoute.ts create mode 100644 lib/src/http/routing/ComplexRoute.ts create mode 100644 lib/src/http/routing/Route.ts create mode 100644 lib/src/http/routing/SimpleRoute.ts create mode 100644 lib/src/support/Rehydratable.ts create mode 100644 lib/src/unit/Routing.ts diff --git a/app/bundle/daton_units.ts b/app/bundle/daton_units.ts index 15554b3..4ba0195 100644 --- a/app/bundle/daton_units.ts +++ b/app/bundle/daton_units.ts @@ -6,3 +6,4 @@ export { default as RoutesUnit } from '../../lib/src/unit/Routes.ts' export { default as HttpKernelUnit } from '../../lib/src/unit/HttpKernel.ts' export { default as ModelsUnit } from '../../orm/src/ModelsUnit.ts' export { default as HttpServerUnit } from '../../lib/src/unit/HttpServer.ts' +export { default as RoutingUnit } from '../../lib/src/unit/Routing.ts' diff --git a/app/configs/server.config.ts b/app/configs/server.config.ts index a954d04..b86707b 100644 --- a/app/configs/server.config.ts +++ b/app/configs/server.config.ts @@ -1,6 +1,8 @@ export default { port: 8080, use_ssl: false, + prefix: '/', + allow_mount_without_prefix: false, powered_by: { enable: true, diff --git a/app/units.ts b/app/units.ts index d8017b9..1ca7f5d 100644 --- a/app/units.ts +++ b/app/units.ts @@ -1,5 +1,13 @@ import { - ConfigUnit, DatabaseUnit, ControllerUnit, MiddlewareUnit, RoutesUnit, HttpKernelUnit, ModelsUnit, HttpServerUnit + ConfigUnit, + DatabaseUnit, + ControllerUnit, + MiddlewareUnit, + RoutesUnit, + HttpKernelUnit, + ModelsUnit, + HttpServerUnit, + RoutingUnit } from './bundle/daton_units.ts' export default [ @@ -10,5 +18,6 @@ export default [ MiddlewareUnit, ControllerUnit, RoutesUnit, + RoutingUnit, HttpServerUnit, ] diff --git a/lib/src/http/Request.ts b/lib/src/http/Request.ts index e30f025..27daf18 100644 --- a/lib/src/http/Request.ts +++ b/lib/src/http/Request.ts @@ -5,6 +5,7 @@ import { HTTPResponse } from './type/HTTPResponse.ts' import Utility from '../service/utility/Utility.ts' import { Injectable } from '../../../di/src/decorator/Injection.ts' import SessionInterface from './session/SessionInterface.ts' +import ActivatedRoute from './routing/ActivatedRoute.ts' @Injectable() export class Request implements HTTPRequest { @@ -13,6 +14,7 @@ export class Request implements HTTPRequest { private _body: any private _query: { [key: string]: any } = {} private _session!: SessionInterface + private _activated_route!: ActivatedRoute public readonly url: string public readonly method: string @@ -37,7 +39,17 @@ export class Request implements HTTPRequest { } set session(session: SessionInterface) { - this._session = session + if ( !this._session ) + this._session = session + } + + get route(): ActivatedRoute { + return this._activated_route + } + + set route(route: ActivatedRoute) { + if ( !this._activated_route ) + this._activated_route = route } constructor( @@ -95,7 +107,7 @@ export class Request implements HTTPRequest { } get path() { - return this.url.split('?')[0] + return this.url.split('?')[0].split('#')[0] } get xhr() { diff --git a/lib/src/http/kernel/Kernel.ts b/lib/src/http/kernel/Kernel.ts index 352e114..456db26 100644 --- a/lib/src/http/kernel/Kernel.ts +++ b/lib/src/http/kernel/Kernel.ts @@ -4,6 +4,7 @@ import AppClass from '../../lifecycle/AppClass.ts' import {Collection} from '../../collection/Collection.ts' import {Service} from '../../../../di/src/decorator/Service.ts' import {Request} from '../Request.ts' +import {Logging} from '../../service/logging/Logging.ts' export interface ModuleRegistrationFluency { before: (other?: Instantiable) => Kernel, @@ -42,6 +43,7 @@ export default class Kernel extends AppClass { } public register(module: Instantiable): ModuleRegistrationFluency { + this.make(Logging).verbose(`Registering HTTP kernel module: ${module.name}`) return { before: (other?: Instantiable): Kernel => { if ( !other ) { diff --git a/lib/src/http/kernel/module/MountActivatedRoute.ts b/lib/src/http/kernel/module/MountActivatedRoute.ts new file mode 100644 index 0000000..32529b5 --- /dev/null +++ b/lib/src/http/kernel/module/MountActivatedRoute.ts @@ -0,0 +1,53 @@ +import Module from '../Module.ts' +import Kernel from '../Kernel.ts' +import PrepareRequest from './PrepareRequest.ts' +import Routing from '../../../unit/Routing.ts' +import {Logging} from '../../../service/logging/Logging.ts' +import {Request} from '../../Request.ts' +import Config from '../../../unit/Config.ts' +import ActivatedRoute from '../../routing/ActivatedRoute.ts' +import {Injectable} from '../../../../../di/src/decorator/Injection.ts' + +@Injectable() +export default class MountActivatedRoute extends Module { + public static register(kernel: Kernel) { + kernel.register(this).after(PrepareRequest) + } + + constructor( + protected readonly routing: Routing, + protected readonly config: Config, + protected readonly logger: Logging, + ) { + super() + } + + public async apply(request: Request): Promise { + let incoming = this.routing.resolve([request.path]) + this.logger.info(`${request.method} ${incoming}`) + + const prefix: string | undefined = this.config.get('server.prefix') + const allow_mount_without_prefix: boolean = this.config.get('server.allow_mount_without_prefix') + + if ( prefix && this.routing.resolve([prefix]) !== '/' ) { + if ( incoming.startsWith(prefix) ) { + incoming = this.routing.resolve([incoming.replace(prefix, '')]) + this.logger.verbose(`Resolved prefix: ${incoming}`) + } else if ( !allow_mount_without_prefix ) { + this.logger.warn(`Will not attempt to mount route with missing prefix: ${incoming}`) + this.logger.debug('To allow this, set the config server.allow_mount_without_prefix = true.') + return request + } + } + + const activated_route: ActivatedRoute | undefined = this.routing.build(incoming) + if ( activated_route ) { + this.logger.verbose(`Resolved activated route: ${activated_route.route.route}`) + request.route = activated_route + } else { + this.logger.verbose(`Unable to resolve route to activated route. No matching route was found.`) + } + + return request + } +} diff --git a/lib/src/http/response/DehydratedStateResponseFactory.ts b/lib/src/http/response/DehydratedStateResponseFactory.ts new file mode 100644 index 0000000..2815d79 --- /dev/null +++ b/lib/src/http/response/DehydratedStateResponseFactory.ts @@ -0,0 +1,16 @@ +import ResponseFactory from './ResponseFactory.ts' +import {Rehydratable} from '../../support/Rehydratable.ts' +import {Request} from '../Request.ts' + +export default class DehydratedStateResponseFactory extends ResponseFactory { + constructor( + public readonly rehydratable: Rehydratable + ) { + super() + } + + public async write(request: Request): Promise { + request.response.body = JSON.stringify(this.rehydratable.dehydrate()) + return request + } +} diff --git a/lib/src/http/response/JSONResponseFactory.ts b/lib/src/http/response/JSONResponseFactory.ts new file mode 100644 index 0000000..46e8055 --- /dev/null +++ b/lib/src/http/response/JSONResponseFactory.ts @@ -0,0 +1,15 @@ +import ResponseFactory from './ResponseFactory.ts' +import {Request} from '../Request.ts' + +export default class JSONResponseFactory extends ResponseFactory { + constructor( + public readonly value: any + ) { + super() + } + + public async write(request: Request): Promise { + request.response.body = JSON.stringify(this.value) + return request + } +} diff --git a/lib/src/http/response/ResponseFactory.ts b/lib/src/http/response/ResponseFactory.ts new file mode 100644 index 0000000..d620b6c --- /dev/null +++ b/lib/src/http/response/ResponseFactory.ts @@ -0,0 +1,6 @@ +import AppClass from '../../lifecycle/AppClass.ts' +import {Request} from '../Request.ts' + +export default abstract class ResponseFactory extends AppClass { + public abstract async write(request: Request): Promise +} diff --git a/lib/src/http/response/StringResponseFactory.ts b/lib/src/http/response/StringResponseFactory.ts new file mode 100644 index 0000000..7d80de0 --- /dev/null +++ b/lib/src/http/response/StringResponseFactory.ts @@ -0,0 +1,15 @@ +import ResponseFactory from './ResponseFactory.ts' +import {Request} from '../Request.ts' + +export default class StringResponseFactory extends ResponseFactory { + constructor( + public readonly value: string, + ) { + super() + } + + public async write(request: Request): Promise { + request.response.body = this.value + return request + } +} diff --git a/lib/src/http/routing/ActivatedRoute.ts b/lib/src/http/routing/ActivatedRoute.ts new file mode 100644 index 0000000..ce227ac --- /dev/null +++ b/lib/src/http/routing/ActivatedRoute.ts @@ -0,0 +1,14 @@ +import AppClass from '../../lifecycle/AppClass.ts' +import {Route, RouteParameters} from './Route.ts' + +export default class ActivatedRoute extends AppClass { + public readonly params: RouteParameters + + constructor( + public readonly incoming: string, + public readonly route: Route, + ) { + super() + this.params = route.build_parameters(incoming) + } +} diff --git a/lib/src/http/routing/ComplexRoute.ts b/lib/src/http/routing/ComplexRoute.ts new file mode 100644 index 0000000..8f2bb9f --- /dev/null +++ b/lib/src/http/routing/ComplexRoute.ts @@ -0,0 +1,29 @@ +import {Route, RouteParameters, RouteSegment} from './Route.ts' +import Utility from '../../service/utility/Utility.ts' +import {make} from '../../../../di/src/global.ts' + +export class ComplexRoute extends Route { + public match(incoming: string): boolean { + const base_length = this.split(this.base).length + const incoming_length = this.split(incoming).length + return base_length === incoming_length // FIXME match! + } + + public build_parameters(incoming: string): RouteParameters { + const utility = make(Utility) + const params: RouteParameters = {} + let current_wildcard: number = 1 + + this.zip(incoming).forEach((segment: RouteSegment) => { + if ( segment.base.indexOf('*') >= 0 ) { + params[`$${current_wildcard}`] = utility.infer(segment.match) + current_wildcard += 1 + } else if ( segment.base.startsWith(':') && segment.base.length > 1 ) { + const name = segment.base.substr(1) + params[name] = utility.infer(segment.match) + } + }) + + return params + } +} diff --git a/lib/src/http/routing/Route.ts b/lib/src/http/routing/Route.ts new file mode 100644 index 0000000..18d57b0 --- /dev/null +++ b/lib/src/http/routing/Route.ts @@ -0,0 +1,41 @@ +import {logger} from "../../service/logging/global.ts"; + +export type RouteParameters = { [key: string]: string } +export type RouteSegment = { base: string, match: string | undefined } +export type ZippedRouteSegments = RouteSegment[] + +export abstract class Route { + constructor( + protected base: string + ) { } + + public abstract match(incoming: string): boolean + public abstract build_parameters(incoming: string): RouteParameters + + public get route() { + return this.base + } + + public split(incoming: string) { + return incoming.toLowerCase().split('/') + } + + public zip(incoming: string) { + const incoming_parts: string[] = this.split(incoming) + const base_parts: string[] = this.split(this.base) + const zipped_parts: ZippedRouteSegments = [] + + if ( incoming_parts.length !== base_parts.length ) { + logger.warn(`Zipping routes with different part lengths! (Base: ${this.base}, Incoming: ${incoming}`) + } + + base_parts.forEach((part, index) => { + zipped_parts.push({ + base: part, + match: (index >= incoming_parts.length ? undefined : incoming_parts[index]), + }) + }) + + return zipped_parts + } +} diff --git a/lib/src/http/routing/SimpleRoute.ts b/lib/src/http/routing/SimpleRoute.ts new file mode 100644 index 0000000..de04ff9 --- /dev/null +++ b/lib/src/http/routing/SimpleRoute.ts @@ -0,0 +1,11 @@ +import {Route, RouteParameters} from './Route.ts' + +export class SimpleRoute extends Route { + public match(incoming: string): boolean { + return incoming === this.base + } + + public build_parameters(incoming: string): RouteParameters { + return {} + } +} diff --git a/lib/src/http/type/HTTPRequest.ts b/lib/src/http/type/HTTPRequest.ts index 9bc0fc8..3c35dda 100644 --- a/lib/src/http/type/HTTPRequest.ts +++ b/lib/src/http/type/HTTPRequest.ts @@ -1,6 +1,7 @@ import { ServerRequest } from '../../external/http.ts' import {HTTPResponse} from './HTTPResponse.ts' import SessionInterface from '../session/SessionInterface.ts' +import ActivatedRoute from '../routing/ActivatedRoute.ts' export interface HTTPProtocol { string: string, @@ -30,4 +31,5 @@ export interface HTTPRequest { secure: boolean session: SessionInterface, + route?: ActivatedRoute, } diff --git a/lib/src/support/Rehydratable.ts b/lib/src/support/Rehydratable.ts new file mode 100644 index 0000000..de51028 --- /dev/null +++ b/lib/src/support/Rehydratable.ts @@ -0,0 +1,16 @@ + +export type JSONState = { [key: string]: string | boolean | number | JSONState | Array } + +export function isJSONState(what: any): what is JSONState { + try { + JSON.stringify(what) + return true + } catch (e) { + return false + } +} + +export interface Rehydratable { + dehydrate(): Promise + rehydrate(state: JSONState): void | Promise +} diff --git a/lib/src/unit/Canonical.ts b/lib/src/unit/Canonical.ts index 01f259f..d51d713 100644 --- a/lib/src/unit/Canonical.ts +++ b/lib/src/unit/Canonical.ts @@ -1,6 +1,7 @@ import LifecycleUnit from '../lifecycle/Unit.ts' import {fs, path} from '../external/std.ts' import {Canon} from './Canon.ts' +import {Logging} from '../service/logging/Logging.ts' export interface CanonicalDefinition { canonical_name: string, @@ -14,6 +15,10 @@ export class Canonical extends LifecycleUnit { protected canonical_item: string = '' protected _items: { [key: string]: T } = {} + public all(): string[] { + return Object.keys(this._items) + } + public get path(): string { return path.resolve(this.base_path) } @@ -23,9 +28,14 @@ export class Canonical extends LifecycleUnit { } public async up() { + const logger = this.make(Logging) for await ( const entry of fs.walk(this.path) ) { - if ( !entry.isFile || !entry.path.endsWith(this.suffix) ) continue + if ( !entry.isFile || !entry.path.endsWith(this.suffix) ) { + if ( entry.isFile ) logger.debug(`Skipping file in canonical path with invalid suffix: ${entry.path}`) + continue + } const def = await this._get_canonical_definition(entry.path) + logger.verbose(`Registering canonical ${this.canonical_item} "${def.canonical_name}" from ${entry.path}.`) this._items[def.canonical_name] = await this.init_canonical_item(def) } this.make(Canon).register_canonical(this) diff --git a/lib/src/unit/HttpKernel.ts b/lib/src/unit/HttpKernel.ts index 89019b9..0838bdd 100644 --- a/lib/src/unit/HttpKernel.ts +++ b/lib/src/unit/HttpKernel.ts @@ -17,6 +17,7 @@ import ModelSessionManagerFactory from '../http/session/ModelSessionManagerFacto import SessionInterface from '../http/session/SessionInterface.ts' import InjectSession from '../http/kernel/module/InjectSession.ts' import PersistSession from '../http/kernel/module/PersistSession.ts' +import MountActivatedRoute from '../http/kernel/module/MountActivatedRoute.ts' @Unit() export default class HttpKernel extends LifecycleUnit { @@ -32,15 +33,7 @@ export default class HttpKernel extends LifecycleUnit { public async up() { this.determine_session_provider() - - PrepareRequest.register(this.kernel) - SetSessionCookie.register(this.kernel) - InjectSession.register(this.kernel) - PersistSession.register(this.kernel) - - if ( this.config.get('server.powered_by.enable') ) { - SetDatonHeaders.register(this.kernel) - } + this.register_modules() } protected determine_session_provider() { @@ -68,4 +61,16 @@ export default class HttpKernel extends LifecycleUnit { this.injector.register_factory(new ModelSessionManagerFactory(ModelClass)) } } + + protected register_modules() { + PrepareRequest.register(this.kernel) + SetSessionCookie.register(this.kernel) + InjectSession.register(this.kernel) + PersistSession.register(this.kernel) + MountActivatedRoute.register(this.kernel) + + if ( this.config.get('server.powered_by.enable') ) { + SetDatonHeaders.register(this.kernel) + } + } } diff --git a/lib/src/unit/HttpServer.ts b/lib/src/unit/HttpServer.ts index 3adefde..91984fb 100644 --- a/lib/src/unit/HttpServer.ts +++ b/lib/src/unit/HttpServer.ts @@ -18,14 +18,11 @@ export default class HttpServer extends LifecycleUnit { public async up() { this._server = serve({ port: 8000 }) - this.logger.success(`HTTP/S server listening on port 8000!`) for await ( const native_request of this._server ) { let req: Request = this.make(Request, native_request) req = await this.kernel.handle(req) - - req.response.body = req.session.get_key() req.response.send() } } diff --git a/lib/src/unit/Routes.ts b/lib/src/unit/Routes.ts index 0ae6cec..ab28866 100644 --- a/lib/src/unit/Routes.ts +++ b/lib/src/unit/Routes.ts @@ -1,6 +1,8 @@ import {Canonical, CanonicalDefinition} from './Canonical.ts' import {isRouterDefinition, RouterDefinition} from '../http/type/RouterDefinition.ts' +import {Unit} from '../lifecycle/decorators.ts' +@Unit() export default class Routes extends Canonical { protected base_path = './app/http/routes' protected canonical_item = 'route_group' diff --git a/lib/src/unit/Routing.ts b/lib/src/unit/Routing.ts new file mode 100644 index 0000000..1948d97 --- /dev/null +++ b/lib/src/unit/Routing.ts @@ -0,0 +1,105 @@ +import LifecycleUnit from '../lifecycle/Unit.ts' +import {Unit} from '../lifecycle/decorators.ts' +import Routes from './Routes.ts' +import {RouterDefinition} from '../http/type/RouterDefinition.ts' +import {Collection} from '../collection/Collection.ts' +import {Route} from '../http/routing/Route.ts' +import {SimpleRoute} from "../http/routing/SimpleRoute.ts"; +import {ComplexRoute} from "../http/routing/ComplexRoute.ts"; +import ActivatedRoute from "../http/routing/ActivatedRoute.ts"; + +export type RouteHandler = () => any +export interface RouteDefinition { + get?: RouteHandler, + post?: RouteHandler, + patch?: RouteHandler, + delete?: RouteHandler, + head?: RouteHandler, + put?: RouteHandler, + connect?: RouteHandler, + options?: RouteHandler, + trace?: RouteHandler, +} + +const verbs = ['get', 'post', 'patch', 'delete', 'head', 'put', 'connect', 'options', 'trace'] + +@Unit() +export default class Routing extends LifecycleUnit { + protected definitions: { [key: string]: RouteDefinition } = {} + protected instances: Collection = new Collection() + + constructor( + protected readonly routes: Routes, + ) { + super() + } + + public async up() { + const route_groups = this.routes.all() + for ( const route_group_name of route_groups ) { + const route_group: RouterDefinition | undefined = this.routes.get(route_group_name) + if ( !route_group ) continue + + const prefix = route_group.prefix || '/' + for ( const verb of verbs ) { + // @ts-ignore + if ( route_group[verb] ) { + // @ts-ignore + const group = route_group[verb] + for ( const key in group ) { + if ( !group.hasOwnProperty(key) ) continue + const handlers = Array.isArray(group[key]) ? group[key] : [group[key]] + const base = this.resolve([prefix, key]) + + if ( !this.definitions[base] ) { + this.definitions[base] = {} + } + + // @ts-ignore + this.definitions[base][verb] = this.build_handler(handlers) // TODO want to rework this + this.instances.push(this.build_route(base)) + } + } + } + } + } + + // TODO + public build_handler(group: string[]): () => any { + return () => {} + } + + public resolve(parts: string[]): string { + const cleaned = parts.map(part => { + if ( part.startsWith('/') ) part = part.substr(1) + if ( part.endsWith('/') ) part = part.slice(0, -1) + return part + }) + + let joined = cleaned.join('/') + if ( joined.startsWith('/') ) joined = joined.substr(1) + if ( joined.endsWith('/') ) joined = joined.slice(0, -1) + return `/${joined}`.toLowerCase() + } + + public build_route(base: string): Route { + if ( !base.includes(':') && !base.includes('*') ) { + return new SimpleRoute(base) + } else { + return new ComplexRoute(base) + } + + // TODO deep-match route + } + + public match(incoming: string): Route | undefined { + return this.instances.firstWhere((route: Route) => route.match(incoming)) + } + + public build(incoming: string): ActivatedRoute | undefined { + const route: Route | undefined = this.match(incoming) + if ( route ) { + return new ActivatedRoute(incoming, route) + } + } +} diff --git a/orm/src/model/Model.ts b/orm/src/model/Model.ts index d2d5cfa..b521d86 100644 --- a/orm/src/model/Model.ts +++ b/orm/src/model/Model.ts @@ -11,6 +11,7 @@ import {logger} from '../../../lib/src/service/logging/global.ts' import {QueryFilter} from './filter.ts' import {BehaviorSubject} from '../../../lib/src/support/BehaviorSubject.ts' import {Scope} from '../builder/Scope.ts' +import {JSONState, Rehydratable} from '../../../lib/src/support/Rehydratable.ts' // TODO separate read/write connections // TODO manual dirty flags @@ -19,7 +20,7 @@ import {Scope} from '../builder/Scope.ts' * Base class for database models. * @extends Builder */ -export abstract class Model> extends Builder { +export abstract class Model> extends Builder implements Rehydratable { /** * The name of the connection this model should run through. * @type string @@ -837,4 +838,19 @@ export abstract class Model> extends Builder { public static qualified_key_name() { return `${this.table_name()}.${this.key}` } + + /** + * Dehydrate the model. Implements Rehydratable interface. + */ + public async dehydrate(): Promise { + return this.to_object() + } + + /** + * Rehydrate the model. Implements Rehydratable interface. + * @param state + */ + public async rehydrate(state: JSONState) { + this.assume_from_source(state) + } }