diff --git a/src/http/kernel/module/MountActivatedRouteHTTPModule.ts b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts new file mode 100644 index 0000000..53c6b9f --- /dev/null +++ b/src/http/kernel/module/MountActivatedRouteHTTPModule.ts @@ -0,0 +1,33 @@ +import {Injectable, Inject} from "@extollo/di" +import {HTTPKernelModule} from "../HTTPKernelModule"; +import {HTTPKernel} from "../HTTPKernel"; +import {Request} from "../../lifecycle/Request"; +import {Routing} from "../../../service/Routing"; +import {ActivatedRoute} from "../../routing/ActivatedRoute"; +import {Logging} from "../../../service/Logging"; + +@Injectable() +export class MountActivatedRouteHTTPModule extends HTTPKernelModule { + @Inject() + protected readonly routing!: Routing + + @Inject() + protected readonly logging!: Logging + + public static register(kernel: HTTPKernel) { + kernel.register(this).before() + } + + public async apply(request: Request): Promise { + const route = this.routing.match(request.method, request.path) + if ( route ) { + this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`) + const activated = new ActivatedRoute(route, request.path) + request.registerSingletonInstance(ActivatedRoute, activated) + } else { + this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`) + } + + return request + } +} diff --git a/src/http/kernel/module/PersistSessionHTTPMiddleware.ts b/src/http/kernel/module/PersistSessionHTTPModule.ts similarity index 88% rename from src/http/kernel/module/PersistSessionHTTPMiddleware.ts rename to src/http/kernel/module/PersistSessionHTTPModule.ts index b9d7772..6a8c711 100644 --- a/src/http/kernel/module/PersistSessionHTTPMiddleware.ts +++ b/src/http/kernel/module/PersistSessionHTTPModule.ts @@ -5,7 +5,7 @@ import {Request} from "../../lifecycle/Request"; import {Session} from "../../session/Session"; @Injectable() -export class PersistSessionHTTPMiddleware extends HTTPKernelModule { +export class PersistSessionHTTPModule extends HTTPKernelModule { public static register(kernel: HTTPKernel) { kernel.register(this).last() } diff --git a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts index 9fd1b37..63944f7 100644 --- a/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts +++ b/src/http/kernel/module/PoweredByHeaderInjectionHTTPModule.ts @@ -1,17 +1,23 @@ import {HTTPKernelModule} from "../HTTPKernelModule"; import {Request} from "../../lifecycle/Request"; -import {Injectable} from "@extollo/di" +import {Injectable, Inject} from "@extollo/di" import {HTTPKernel} from "../HTTPKernel"; +import {Config} from "../../../service/Config"; @Injectable() export class PoweredByHeaderInjectionHTTPModule extends HTTPKernelModule { + @Inject() + protected readonly config!: Config; + public static register(kernel: HTTPKernel) { kernel.register(this).after() } public async apply(request: Request) { - // FIXME make this configurable - request.response.setHeader('X-Powered-By', 'Extollo') + if ( !this.config.get('server.poweredBy.hide', false) ) { + request.response.setHeader('X-Powered-By', this.config.get('server.poweredBy.header', 'Extollo')) + } + return request } } diff --git a/src/http/kernel/module/SetSessionCookieHTTPModule.ts b/src/http/kernel/module/SetSessionCookieHTTPModule.ts index f3570e2..b00ef59 100644 --- a/src/http/kernel/module/SetSessionCookieHTTPModule.ts +++ b/src/http/kernel/module/SetSessionCookieHTTPModule.ts @@ -19,7 +19,7 @@ export class SetSessionCookieHTTPModule extends HTTPKernelModule { const session = `${uuid_v4()}-${uuid_v4()}` this.logging.verbose(`Starting session: ${session}`) - request.cookies.set('extollo.session', session) // FIXME allow configuring this + request.cookies.set('extollo.session', session) } return request } diff --git a/src/http/lifecycle/Request.ts b/src/http/lifecycle/Request.ts index 09ae976..c03f293 100644 --- a/src/http/lifecycle/Request.ts +++ b/src/http/lifecycle/Request.ts @@ -5,6 +5,7 @@ import {HTTPCookieJar} from "../kernel/HTTPCookieJar"; import {TLSSocket} from "tls"; import * as url from "url"; import {Response} from "./Response"; +import {ActivatedRoute} from "../routing/ActivatedRoute"; // FIXME - add others? export type HTTPMethod = 'post' | 'get' | 'patch' | 'put' | 'delete' | 'unknown'; diff --git a/src/http/routing/ActivatedRoute.ts b/src/http/routing/ActivatedRoute.ts new file mode 100644 index 0000000..46cf713 --- /dev/null +++ b/src/http/routing/ActivatedRoute.ts @@ -0,0 +1,23 @@ +import {ErrorWithContext} from "@extollo/util"; +import {Route} from "./Route"; + +export class ActivatedRoute { + public readonly params: {[key: string]: string} + + constructor( + public readonly route: Route, + public readonly path: string + ) { + const params = route.extract(path) + if ( !params ) { + const error = new ErrorWithContext('Cannot get params for route. Path does not match.') + error.context = { + matchedRoute: String(route), + requestPath: path, + } + throw error + } + + this.params = params + } +} diff --git a/src/http/routing/Route.ts b/src/http/routing/Route.ts new file mode 100644 index 0000000..fc64add --- /dev/null +++ b/src/http/routing/Route.ts @@ -0,0 +1,141 @@ +import {AppClass} from "../../lifecycle/AppClass"; +import {HTTPMethod, Request} from "../lifecycle/Request"; +import {Application} from "../../lifecycle/Application"; +import {RouteGroup} from "./RouteGroup"; + +export type RouteHandler = (request: Request) => void | Promise // FIXME want to do some improvements here + +// TODO middleware, domains, named routes - support this on groups as well + +export class Route extends AppClass { + private static registeredRoutes: Route[] = [] + private static registeredGroups: RouteGroup[] = [] + + private static compiledGroupStack: RouteGroup[] = [] + + public static registerGroup(group: RouteGroup) { + this.registeredGroups.push(group) + } + + public static async compile(): Promise { + let registeredRoutes = this.registeredRoutes + const registeredGroups = this.registeredGroups + + this.registeredRoutes = [] + this.registeredGroups = [] + + const stack = [...this.compiledGroupStack].reverse() + for ( const route of registeredRoutes ) { + for ( const group of stack ) { + route.prepend(group.prefix) + } + } + + for ( const group of registeredGroups ) { + this.compiledGroupStack.push(group) + await group.group() + + const childCompilation = await this.compile() + registeredRoutes = registeredRoutes.concat(childCompilation) + + this.compiledGroupStack.pop() + } + + return registeredRoutes + } + + public static endpoint(method: HTTPMethod | HTTPMethod[], definition: string, handler: RouteHandler) { + const route = new Route(method, handler, definition) + this.registeredRoutes.push(route) + return route + } + + public static get(definition: string, handler: RouteHandler) { + return this.endpoint('get', definition, handler) + } + + public static post(definition: string, handler: RouteHandler) { + return this.endpoint('post', definition, handler) + } + + public static put(definition: string, handler: RouteHandler) { + return this.endpoint('put', definition, handler) + } + + public static patch(definition: string, handler: RouteHandler) { + return this.endpoint('patch', definition, handler) + } + + public static delete(definition: string, handler: RouteHandler) { + return this.endpoint('delete', definition, handler) + } + + public static any(definition: string, handler: RouteHandler) { + return this.endpoint(['get', 'put', 'patch', 'post', 'delete'], definition, handler) + } + + public static group(prefix: string, group: () => void | Promise) { + const grp = Application.getApplication().make(RouteGroup, group, prefix) + this.registeredGroups.push(grp) + return grp + } + + constructor( + protected method: HTTPMethod | HTTPMethod[], + protected handler: RouteHandler, + protected route: string + ) { super() } + + public match(method: HTTPMethod, potential: string): boolean { + if ( Array.isArray(this.method) && !this.method.includes(method) ) return false + else if ( !Array.isArray(this.method) && this.method !== method ) return false + + return !!this.extract(potential) + } + + public extract(potential: string): {[key: string]: string} | undefined { + const routeParts = (this.route.startsWith('/') ? this.route.substr(1) : this.route).split('/') + const potentialParts = (potential.startsWith('/') ? potential.substr(1) : potential).split('/') + + const params: any = {} + let wildcardIdx = 0 + + for ( let i = 0; i < routeParts.length; i += 1 ) { + const part = routeParts[i] + + if ( part === '**' ) { + params[wildcardIdx] = potentialParts.slice(i).join('/') + return params + } + + if ( (potentialParts.length - 1) < i ) { + return + } + + if ( part === '*' ) { + params[wildcardIdx] = potentialParts[i] + wildcardIdx += 1 + } else if ( part.startsWith(':') ) { + params[part.substr(1)] = potentialParts[i] + } else if ( potentialParts[i] !== part ) { + return + } + } + + // If we got here, we didn't find a ** + // So, if the lengths are different, fail + if ( routeParts.length !== potentialParts.length ) return + return params + } + + private prepend(prefix: string) { + if ( !prefix.endsWith('/') ) prefix = `${prefix}/` + if ( this.route.startsWith('/') ) this.route = this.route.substring(1) + this.route = `${prefix}${this.route}` + } + + toString() { + const method = Array.isArray(this.method) ? this.method : [this.method] + return `${method.join('|')} -> ${this.route}` + } +} diff --git a/src/http/routing/RouteGroup.ts b/src/http/routing/RouteGroup.ts new file mode 100644 index 0000000..f5aa4be --- /dev/null +++ b/src/http/routing/RouteGroup.ts @@ -0,0 +1,14 @@ +import {AppClass} from "../../lifecycle/AppClass"; + +export class RouteGroup extends AppClass { + private static currentGroupNesting: RouteGroup[] = [] + + public static getCurrentGroupHierarchy(): RouteGroup[] { + return [...this.currentGroupNesting] + } + + constructor( + public readonly group: () => void | Promise, + public readonly prefix: string + ) { super() } +} diff --git a/src/http/session/SessionFactory.ts b/src/http/session/SessionFactory.ts index 36dfbbd..cdbd1d6 100644 --- a/src/http/session/SessionFactory.ts +++ b/src/http/session/SessionFactory.ts @@ -3,25 +3,29 @@ import { Container, DependencyRequirement, PropertyDependency, + isInstantiable, DEPENDENCY_KEYS_METADATA_KEY, DEPENDENCY_KEYS_PROPERTY_METADATA_KEY } from "@extollo/di" -import {Collection} from "@extollo/util" +import {Collection, ErrorWithContext} from "@extollo/util" import {MemorySession} from "./MemorySession"; import {Session} from "./Session"; import {Logging} from "../../service/Logging"; +import {Config} from "../../service/Config"; export class SessionFactory extends AbstractFactory { protected readonly logging: Logging + protected readonly config: Config constructor() { super({}) this.logging = Container.getContainer().make(Logging) + this.config = Container.getContainer().make(Config) } - produce(dependencies: any[], parameters: any[]): any { + produce(dependencies: any[], parameters: any[]): Session { this.logging.warn(`You are using the default memory-based session driver. It is recommended you configure a persistent session driver instead.`) - return new MemorySession() // FIXME allow configuring + return new (this.getSessionClass()) } match(something: any) { @@ -29,14 +33,14 @@ export class SessionFactory extends AbstractFactory { } getDependencyKeys(): Collection { - const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.token) + const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getSessionClass()) if ( meta ) return meta return new Collection() } getInjectedProperties(): Collection { const meta = new Collection() - let currentToken = MemorySession // FIXME allow configuring + let currentToken = this.getSessionClass() do { const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken) @@ -46,4 +50,19 @@ export class SessionFactory extends AbstractFactory { return meta } + + protected getSessionClass() { + const SessionClass = this.config.get('server.session.driver', MemorySession) + + // TODO check that session class is valid + if ( !isInstantiable(SessionClass) || !(SessionClass.prototype instanceof Session) ) { + const e = new ErrorWithContext('Provided session class does not extend from @extollo/lib.Session'); + e.context = { + config_key: 'server.session.driver', + class: SessionClass.toString(), + } + } + + return SessionClass + } } diff --git a/src/index.ts b/src/index.ts index e50997e..9c4d985 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,24 @@ export * from './lifecycle/Application' export * from './lifecycle/AppClass' export * from './lifecycle/Unit' +export * from './http/kernel/module/InjectSessionHTTPModule' +export * from './http/kernel/module/PersistSessionHTTPModule' +export * from './http/kernel/module/PoweredByHeaderInjectionHTTPModule' +export * from './http/kernel/module/SetSessionCookieHTTPModule' + export * from './http/kernel/HTTPKernel' export * from './http/kernel/HTTPKernelModule' +export * from './http/kernel/HTTPCookieJar' + +export * from './http/lifecycle/Request' +export * from './http/lifecycle/Response' + +export * from './http/routing/Route' +export * from './http/routing/RouteGroup' + +export * from './http/session/Session' +export * from './http/session/SessionFactory' +export * from './http/session/MemorySession' export * from './http/Controller' @@ -18,3 +34,4 @@ export * from './service/FakeCanonical' export * from './service/Config' export * from './service/Controllers' export * from './service/HTTPServer' +export * from './service/Routing' diff --git a/src/service/HTTPServer.ts b/src/service/HTTPServer.ts index 3707767..c1a2d21 100644 --- a/src/service/HTTPServer.ts +++ b/src/service/HTTPServer.ts @@ -7,7 +7,8 @@ import {HTTPKernel} from "../http/kernel/HTTPKernel"; import {PoweredByHeaderInjectionHTTPModule} from "../http/kernel/module/PoweredByHeaderInjectionHTTPModule"; import {SetSessionCookieHTTPModule} from "../http/kernel/module/SetSessionCookieHTTPModule"; import {InjectSessionHTTPModule} from "../http/kernel/module/InjectSessionHTTPModule"; -import {PersistSessionHTTPMiddleware} from "../http/kernel/module/PersistSessionHTTPMiddleware"; +import {PersistSessionHTTPModule} from "../http/kernel/module/PersistSessionHTTPModule"; +import {MountActivatedRouteHTTPModule} from "../http/kernel/module/MountActivatedRouteHTTPModule"; @Singleton() export class HTTPServer extends Unit { @@ -26,7 +27,8 @@ export class HTTPServer extends Unit { PoweredByHeaderInjectionHTTPModule.register(this.kernel) SetSessionCookieHTTPModule.register(this.kernel) InjectSessionHTTPModule.register(this.kernel) - PersistSessionHTTPMiddleware.register(this.kernel) + PersistSessionHTTPModule.register(this.kernel) + MountActivatedRouteHTTPModule.register(this.kernel) await new Promise((res, rej) => { this.server = createServer(this.handler) diff --git a/src/service/Logging.ts b/src/service/Logging.ts index f292fb2..598c28e 100644 --- a/src/service/Logging.ts +++ b/src/service/Logging.ts @@ -77,5 +77,7 @@ export class Logging { return e.stack.split(/\s+at\s+/) .slice(level) .map((x: string): string => x.trim().split(' (')[0].split('.')[0].split(':')[0])[0] + .split('/') + .reverse()[0] } } diff --git a/src/service/Routing.ts b/src/service/Routing.ts new file mode 100644 index 0000000..226a374 --- /dev/null +++ b/src/service/Routing.ts @@ -0,0 +1,44 @@ +import {Singleton, Inject} from "@extollo/di" +import {UniversalPath, Collection} from "@extollo/util" +import {Unit} from "../lifecycle/Unit" +import {Logging} from "./Logging" +import {Route} from "../http/routing/Route"; +import {HTTPMethod} from "../http/lifecycle/Request"; + +@Singleton() +export class Routing extends Unit { + @Inject() + protected readonly logging!: Logging + + protected compiledRoutes: Collection = new Collection() + + public async up() { + for await ( const entry of this.path.walk() ) { + if ( !entry.endsWith('.routes.js') ) { + this.logging.debug(`Skipping routes file with invalid suffix: ${entry}`) + continue + } + + this.logging.info(`Importing routes from: ${entry}`) + await import(entry) + } + + this.logging.info('Compiling routes...') + this.compiledRoutes = new Collection(await Route.compile()) + + this.logging.info(`Compiled ${this.compiledRoutes.length} route(s).`) + this.compiledRoutes.each(route => { + this.logging.verbose(`${route}`) + }) + } + + public match(method: HTTPMethod, path: string): Route | undefined { + return this.compiledRoutes.firstWhere(route => { + return route.match(method, path) + }) + } + + public get path(): UniversalPath { + return this.app().appPath('http', 'routes') + } +}