From 8d5e801b105080d2998c1feb65926870792693fe Mon Sep 17 00:00:00 2001 From: Dave Wasmer Date: Sun, 19 Mar 2017 23:53:10 -0600 Subject: [PATCH] feat(runtime): add resolver, refactor container The resolver and related refactor enables: 1. Lazy loading of app code, which is now the default. It doesn't preclude eager loading though, and future work could add an eager load option for production environments. 2. Customizable directory structures. This is generally not a good idea for the casual use case, but it's possible that some additional type of code asset might need different lookup rules (i.e. test files) 3. Cleaner addon / application loading. The application logger is now customizable by simply adding `app/logger.js` (as long as the interface matches). Same for the `app/router.js`. This also solves some outstanding issues: fixes #285, #284 --- app/logger.ts | 1 + app/router.ts | 1 + lib/runtime/action.ts | 12 +- lib/runtime/addon.ts | 184 +++---------------------- lib/runtime/application.ts | 77 +++++++---- lib/runtime/container.ts | 271 +++++++++++++++++++------------------ lib/runtime/resolver.ts | 144 ++++++++++++++++++++ lib/runtime/router.ts | 2 +- lib/utils/types.ts | 9 ++ package.json | 1 + test/unit/addon-test.ts | 2 - yarn.lock | 4 + 12 files changed, 377 insertions(+), 331 deletions(-) create mode 100644 app/logger.ts create mode 100644 app/router.ts create mode 100644 lib/runtime/resolver.ts create mode 100644 lib/utils/types.ts diff --git a/app/logger.ts b/app/logger.ts new file mode 100644 index 00000000..fb642440 --- /dev/null +++ b/app/logger.ts @@ -0,0 +1 @@ +export { default } from '../lib/runtime/logger'; \ No newline at end of file diff --git a/app/router.ts b/app/router.ts new file mode 100644 index 00000000..20d47f74 --- /dev/null +++ b/app/router.ts @@ -0,0 +1 @@ +export { default } from '../lib/runtime/router'; \ No newline at end of file diff --git a/lib/runtime/action.ts b/lib/runtime/action.ts index 52ddbe72..7a018f66 100644 --- a/lib/runtime/action.ts +++ b/lib/runtime/action.ts @@ -1,8 +1,3 @@ -import Instrumentation from '../metal/instrumentation'; -import Model from '../data/model'; -import Response from './response'; -import * as http from 'http'; -import * as createDebug from 'debug'; import { assign, capitalize, @@ -12,6 +7,11 @@ import { compact, map } from 'lodash'; +import Instrumentation from '../metal/instrumentation'; +import Model from '../data/model'; +import Response from './response'; +import * as http from 'http'; +import * as createDebug from 'debug'; import * as assert from 'assert'; import eachPrototype from '../metal/each-prototype'; import DenaliObject from '../metal/object'; @@ -170,7 +170,7 @@ abstract class Action extends DenaliObject { this.request = options.request; this.logger = options.logger; this.container = options.container; - this.config = this.container.config; + this.config = this.container.lookup('app:main').config; } /** diff --git a/lib/runtime/addon.ts b/lib/runtime/addon.ts index 95310d3e..4a409c38 100644 --- a/lib/runtime/addon.ts +++ b/lib/runtime/addon.ts @@ -1,3 +1,8 @@ +import { + forEach, + omit, + noop + } from 'lodash'; import * as path from 'path'; import * as fs from 'fs-extra'; import * as glob from 'glob'; @@ -7,11 +12,6 @@ import { sync as isDirectory } from 'is-directory'; import requireDir from '../utils/require-dir'; import * as tryRequire from 'try-require'; import * as stripExtension from 'strip-extension'; -import { - forEach, - omit, - noop - } from 'lodash'; import { singularize } from 'inflection'; import * as createDebug from 'debug'; import DenaliObject from '../metal/object'; @@ -19,6 +19,7 @@ import Container from './container'; import Logger from './logger'; import Router from './router'; import Application from './application'; +import Resolver from './resolver'; const debug = createDebug('denali:runtime:addon'); @@ -32,7 +33,6 @@ export interface AddonOptions { environment: string; dir: string; container: Container; - logger: Logger; pkg?: any; } @@ -85,57 +85,36 @@ export default class Addon extends DenaliObject { public dir: string; /** - * The list of child addons that this addon contains - */ - public addons: Addon[]; - - /** - * The application logger instance + * The package.json for this addon * * @since 0.1.0 */ - protected logger: Logger; + public pkg: any; /** - * The package.json for this addon + * The resolver instance to use with this addon. + * + * @since 0.1.0 */ - public pkg: any; + public resolver: Resolver; /** - * Internal cache of the configuration that is specific to this addon + * The consuming application container instance + * + * @since 0.1.0 */ - public _config: any; + public container: Container; constructor(options: AddonOptions) { super(); this.environment = options.environment; this.dir = options.dir; + this.pkg = options.pkg || tryRequire(findup('package.json', { cwd: this.dir })); this.container = options.container; - this.logger = options.logger; - this.pkg = options.pkg || tryRequire(findup('package.json', { cwd: this.dir })); + this.resolver = this.resolver || new Resolver(this.dir); + this.container.addResolver(this.resolver); this.container.register(`addon:${ this.pkg.name }@${ this.pkg.version }`, this); - this._config = this.loadConfig(); - } - - /** - * The app directory for this addon. Override to customize where the app directory is stored in - * your addon. - * - * @since 0.1.0 - */ - get appDir(): string { - return path.join(this.dir, 'app'); - } - - /** - * The config directory for this addon. Override this to customize where the config files are - * stored in your addon. - * - * @since 0.1.0 - */ - public get configDir(): string { - return path.join(this.dir, 'config'); } /** @@ -148,131 +127,6 @@ export default class Addon extends DenaliObject { return (this.pkg && this.pkg.name) || 'anonymous-addon'; } - /** - * Load the config for this addon. The standard `config/environment.js` file is loaded by default. - * `config/middleware.js` and `config/routes.js` are ignored. All other userland config files are - * loaded into the container under their filenames. - * - * Config files are all .js files, so just the exported functions are loaded here. The functions - * are run later, during application initialization, to generate the actual runtime configuration. - */ - protected loadConfig(): any { - let config = this.loadConfigFile('environment') || function() { - return {}; - }; - if (isDirectory(this.configDir)) { - let allConfigFiles = requireDir(this.configDir, { recurse: false }); - let extraConfigFiles = omit(allConfigFiles, 'environment', 'middleware', 'routes'); - forEach(extraConfigFiles, (configModule, configFilename) => { - let configModulename = stripExtension(configFilename); - this.container.register(`config:${ configModulename }`, configModule); - }); - } - return config; - } - - /** - * Load the addon's various assets. Loads child addons first, meaning that addon loading is - * depth-first recursive. - */ - public load(): void { - debug(`loading ${ this.pkg.name }`); - this.loadInitializers(); - this.loadMiddleware(); - this.loadApp(); - this.loadRoutes(); - } - - /** - * Load the initializers for this addon. Initializers live in `config/initializers`. - */ - protected loadInitializers(): void { - let initializersDir = path.join(this.configDir, 'initializers'); - if (isDirectory(initializersDir)) { - let initializers = requireDir(initializersDir); - forEach(initializers, (initializer, name) => { - this.container.register(`initializer:${ name }`, initializer); - }); - } - } - - /** - * Load the middleware for this addon. Middleware is specified in `config/middleware.js`. The file - * should export a function that accepts the router as it's single argument. You can then attach - * any middleware you'd like to that router, and it will execute before any route handling by - * Denali. - * - * Typically this is useful to register global middleware, i.e. a CORS handler, cookie parser, - * etc. - * - * If you want to run some logic before certain routes only, try using filters on your actions - * instead. - */ - protected loadMiddleware(): void { - this._middleware = this.loadConfigFile('middleware') || noop; - } - - /** - * The middleware factory for this addon. - */ - public _middleware: (router: Router, application: Application) => void; - - /** - * Loads the routes for this addon. Routes are defined in `config/routes.js`. The file should - * export a function that defines routes. See the Routing guide for details on how to define - * routes. - */ - protected loadRoutes(): void { - this._routes = this.loadConfigFile('routes') || noop; - } - - /** - * The routes factory for this addon. - */ - public _routes: (router: Router) => void; - - /** - * Load the app assets for this addon. These are the various classes that live under `app/`, - * including actions, models, etc., as well as any custom class types. - * - * Files are loaded into the container under their folder's namespace, so `app/roles/admin.js` - * would be registered as 'role:admin' in the container. Deeply nested folders become part of the - * module name, i.e. `app/roles/employees/manager.js` becomes 'role:employees/manager'. - * - * Non-JS files are loaded as well, and their container names include the extension, so - * `app/mailer/welcome.html` becomes `mail:welcome.html`. - */ - protected loadApp(): void { - debug(`loading app for ${ this.pkg.name }`); - if (fs.existsSync(this.appDir)) { - eachDir(this.appDir, (dirname) => { - debug(`loading ${ dirname } for ${ this.pkg.name }`); - let dir = path.join(this.appDir, dirname); - let type = singularize(dirname); - - glob.sync('**/*', { cwd: dir }).forEach((filepath) => { - let modulepath = stripExtension(filepath); - if (filepath.endsWith('.js')) { - let Class = require(path.join(dir, filepath)); - Class = Class.default || Class; - this.container.register(`${ type }:${ modulepath }`, Class); - } else if (filepath.endsWith('.json')) { - let mod = require(path.join(dir, filepath)); - this.container.register(`${ type }:${ modulepath }`, mod.default || mod); - } - }); - }); - } - } - - /** - * Helper to load a file from the config directory - */ - protected loadConfigFile(filename: string): any { - let configModule = tryRequire(path.join(this.configDir, `${ filename }.js`)); - return configModule && (configModule.default || configModule); - } - /** * A hook to perform any shutdown actions necessary to gracefully exit the application, i.e. close * database/socket connections. diff --git a/lib/runtime/application.ts b/lib/runtime/application.ts index 6b3218c7..fb301716 100644 --- a/lib/runtime/application.ts +++ b/lib/runtime/application.ts @@ -1,10 +1,12 @@ +import { + values, + constant, + noop +} from 'lodash'; import * as path from 'path'; import * as http from 'http'; import * as https from 'https'; import { each, all } from 'bluebird'; -import { - values -} from 'lodash'; import Addon, { AddonOptions } from './addon'; import topsort from '../utils/topsort'; import Router from './router'; @@ -13,6 +15,7 @@ import Container from './container'; import findPlugins from 'find-plugins'; import * as tryRequire from 'try-require'; import * as createDebug from 'debug'; +import { Vertex } from '../utils/topsort'; const debug = createDebug('denali:application'); @@ -71,29 +74,34 @@ export default class Application extends Addon { */ public container: Container; + /** + * The logger instance for the entire application + * + * @since 0.1.0 + */ + public logger: Logger; + + /** + * List of child addons for this app (one-level deep only, i.e. no addons-of-addons are included) + * + * @since 0.1.0 + */ + public addons: Addon[]; + constructor(options: ApplicationOptions) { - if (!options.container) { - options.container = new Container(); - options.logger = options.logger || new Logger(); - options.router = options.router || new Router({ - container: options.container, - logger: options.logger - }); - options.container.register('router:main', options.router); - options.container.register('logger:main', options.logger); - } - super(options); - this.container.register('application:main', this); - this.router = this.container.lookup('router:main'); - this.logger = this.container.lookup('logger:main'); + super(Object.assign(options, { container: new Container() })); + + // Setup some helpful container shortcuts + this.container.register('app:main', this); + this.router = this.container.lookup('app:router'); + this.logger = this.container.lookup('app:logger'); + + // Find addons for this application this.addons = this.buildAddons(options.addons || []); + // Generate config first, since the loading process may need it this.config = this.generateConfig(); - this.addons.forEach((addon) => { - addon.load(); - }); - this.load(); this.compileRouter(); } @@ -121,7 +129,6 @@ export default class Application extends Addon { let addon = new AddonClass({ environment: this.environment, container: this.container, - logger: this.logger, dir: plugin.dir, pkg: plugin.pkg }); @@ -145,11 +152,15 @@ export default class Application extends Addon { * are. */ private generateConfig(): any { - let config = this._config(this.environment); + let appConfig = this.resolver.retrieve('config:environment') || constant({}); + let config = appConfig(this.environment); config.environment = this.environment; this.container.register('config:environment', config); this.addons.forEach((addon) => { - addon._config(this.environment, config); + let addonConfig = addon.resolver.retrieve('config:environment'); + if (addonConfig) { + addonConfig(this.environment, config); + } }); return config; } @@ -158,13 +169,21 @@ export default class Application extends Addon { * Assemble middleware and routes */ private compileRouter(): void { + // Load addon middleware first this.addons.forEach((addon) => { - addon._middleware(this.router, this); + let addonMiddleware = addon.resolver.retrieve('config:middleware') || noop; + addonMiddleware(this.router, this); }); - this._middleware(this.router, this); - this._routes(this.router); + // Then load app middleware + let appMiddleware = this.resolver.retrieve('config:middleware') || noop; + appMiddleware(this.router, this); + // Load app routes first so they have precedence + let appRoutes = this.resolver.retrieve('config:routes') || noop; + appRoutes(this.router, this); + // Load addon routes in reverse order so routing precedence matches addon load order this.addons.reverse().forEach((addon) => { - addon._routes(this.router); + let addonRoutes = addon.resolver.retrieve('config:routes') || noop; + addonRoutes(this.router); }); } @@ -211,7 +230,7 @@ export default class Application extends Addon { * @since 0.1.0 */ public async runInitializers(): Promise { - let initializers = topsort(values(this.container.lookupAll('initializer'))); + let initializers = topsort(values(this.container.lookupAll('initializer'))); await each(initializers, async (initializer: Initializer) => { await initializer.initialize(this); }); diff --git a/lib/runtime/container.ts b/lib/runtime/container.ts index 12b19dc0..4b29dd14 100644 --- a/lib/runtime/container.ts +++ b/lib/runtime/container.ts @@ -1,40 +1,33 @@ import { - findKey, - upperFirst, camelCase, - keys, - merge + forOwn, + isObject, + forEach, + defaults } from 'lodash'; +import * as dedent from 'dedent-js'; +import { Dict } from '../utils/types'; import DenaliObject from '../metal/object'; -import Logger from './logger'; -import Model from '../data/model'; -import Serializer from '../data/serializer'; -import OrmAdapter from '../data/orm-adapter'; -import Service from './service'; +import Resolver from './resolver'; +import { assign, mapValues } from 'lodash'; -interface ParsedName { +export interface ParsedName { fullName: string; type: string; modulePath: string; moduleName: string; } -interface FallbackGetter { - (): string; -} - -interface LookupOptions { +export interface ContainerOptions { containerize?: boolean; singleton?: boolean; - fallback?: string | FallbackGetter; - original?: ParsedName; } -interface ModuleRegistry { - [moduleName: string]: any; +const DEFAULT_CONTAINER_OPTIONS = { + containerize: true, + singleton: true } -type Constructor = new(...args: any[]) => T; /** * The Container houses all the various classes that makeup a Denali app's @@ -48,32 +41,43 @@ type Constructor = new(...args: any[]) => T; */ export default class Container extends DenaliObject { + constructor(options: { resolver?: Resolver } = {}) { + super(); + this.resolvers.push(options.resolver || new Resolver(process.cwd())); + this.registerOptions('action', { singleton: false }); + } + /** * An internal cache of lookups and their resolved values */ - private _cache: ModuleRegistry = {}; + private lookups: Map = new Map(); /** - * The internal cache of available references + * Options for entries in this container. Keyed on the parsedName.fullName, each entry supplies + * metadata for how the container entry should be treated. */ - private _registry: ModuleRegistry = {}; + private options: Map = new Map(); /** - * A reference to the application config - * - * @since 0.1.0 + * Optional resolvers to use as a fallback if the default resolver is unable to resolve a lookup. + * Usually these are the resolvers for child addons, but you could also use a fallback resolver + * to support an alternative directory structure for your app. NOTE: this is NOT recommended, and + * may break compatibility with poorly designed addons as well as certainly CLI features. */ - public get config(): any { - return this.lookup('config:environment'); - } + private resolvers: Resolver[] = []; + + /** + * Holds options for how to handle constructing member objects + */ + private memberOptions: Map = new Map(); /** - * A reference to the application logger + * Add a fallback resolver to the bottom of the fallback queue. * * @since 0.1.0 */ - public get logger(): Logger { - return this.lookup('logger:main'); + public addResolver(resolver: Resolver) { + this.resolvers.push(resolver); } /** @@ -81,124 +85,134 @@ export default class Container extends DenaliObject { * * @since 0.1.0 */ - public register(name: string, value: any): void { - let parsedName = this.parseName(name); - this._registry[parsedName.fullName] = value; + public register(name: string, value: any, options?: ContainerOptions): void { + this.resolvers[0].register(name, value); + if (options) { + this.registerOptions(name, options); + } } /** - * Lookup a value in the container. Uses type specific lookup logic if available. + * Set options for how the given member will be constructed. Options passed in are merged with any + * existing options - they do not replace them entirely. * * @since 0.1.0 */ - public lookup(name: string): any { - let parsedName = this.parseName(name); - let lookupMethod = this[`lookup${ upperFirst(camelCase(parsedName.type)) }`] || this._lookupOther; - return lookupMethod.call(this, parsedName); + public registerOptions(name: string, options: ContainerOptions = {}): void { + let { fullName } = parseName(name); + let currentOptions = this.memberOptions.get(fullName); + this.memberOptions.set(fullName, assign(currentOptions, options)); } - [key: string]: any; - /** - * Lookup all modules of a specific type in the container. Returns an object of all the modules - * keyed by their module path (i.e. `role:employees/manager` would be found under - * `lookupAll('role')['employees/manager']` + * Get the given option for the given member of the container * * @since 0.1.0 */ - public lookupAll(type: string): { [moduleName: string]: any } { - return keys(this._registry).filter((fullName) => { - return this.parseName(fullName).type === type; - }).reduce((typeMap: ModuleRegistry, fullName) => { - typeMap[this.parseName(fullName).modulePath] = this.lookup(fullName); - return typeMap; - }, {}); + public optionFor(name: string, option: keyof ContainerOptions): any { + let { fullName } = parseName(name); + let options = this.memberOptions.get(fullName) || {}; + return defaults(options, DEFAULT_CONTAINER_OPTIONS)[option]; } /** - * The base lookup method that most other lookup methods delegate to. Attempts to lookup a cached - * resolution for the parsedName provided. If none is found, performs the lookup and caches it - * for future retrieval + * Lookup a value in the container. Uses type specific lookup logic if available. + * + * @since 0.1.0 */ - private _lookupOther(parsedName: ParsedName, options: LookupOptions = { containerize: false, singleton: false }) { - // Cache all this containerization / singleton instantiation, etc - if (!this._cache[parsedName.fullName]) { - let Class = this._registry[parsedName.fullName]; - - // If lookup succeeded, handle any first-time lookup chores - if (Class) { - if (Class.containerize || options.containerize) { - Class.container = this; - Class.prototype.container = this; - } - if (Class.singleton || options.singleton) { - Class = new Class(); + public lookup(name: string, lookupOptions: { loose?: boolean, raw?: boolean } = {}): any { + let parsedName = parseName(name); + + if (!this.lookups.has(parsedName.fullName)) { + + // Find the member with the top level resolver + let object; + forEach(this.resolvers, (resolver) => { + object = resolver.retrieve(parsedName); + return !object; // Break loop if we found something + }); + + // Handle a bad lookup + if (!object) { + // Allow failed lookups (opt-in) + if (lookupOptions.loose) { + this.lookups.set(parsedName.fullName, null); + return null; } + throw new Error(dedent` + No such ${ parsedName.type } found: '${ parsedName.moduleName }' + Available "${ parsedName.type }" container entries: + ${ Object.keys(this.lookupAll(parsedName.type)) } + `); + } - // If the lookup failed, allow for a fallback - } else if (options.fallback) { - let fallback = result(options.fallback); - let fallbackOptions = merge(options, { - fallback: null, - original: parsedName - }); - Class = this._lookupOther(this.parseName(fallback), fallbackOptions); - - // If the lookup and fallback failed, bail - } else { - let message = `No such ${ parsedName.type } found: '${ parsedName.moduleName }'`; - if (options.original) { - message += `. Fallback lookup '${ options.original.fullName }' was also not found.`; + // Create a clone of the object so that we won't share a reference with other containers. + // This is important for tests especially - since our test runner (ava) runs tests from the + // same file concurrently, each test's container would end up using the same underlying + // object (since Node's require caches modules), so mutations to the object in one test would + // change it for all others. So we need to clone the object so our container gets a unique + // in-memory object to work with. + object = this.createLocalClone(object); + + // Inject container references + if (this.optionFor(parsedName.fullName, 'containerize')) { + object.container = this; + if (object.prototype) { + object.prototype.container = this; } - message += `\nAvailable "${ parsedName.type }" container entries:\n`; - message += Object.keys(this.lookupAll(parsedName.type)); - throw new Error(message); } - // Update the cache with either the successful lookup, or the fallback - this._cache[parsedName.fullName] = Class; + if (this.optionFor(parsedName.fullName, 'singleton')) { + object = new object(); + } + + this.lookups.set(parsedName.fullName, object); } - return this._cache[parsedName.fullName]; - } - /** - * Lookup an ORM adapter. If not found, falls back to the application ORM adapter as determined - * by the `ormAdapter` config property. - */ - private lookupOrmAdapter(parsedName: ParsedName): OrmAdapter { - return this._lookupOther(parsedName, { - fallback: () => { - if (!this.config.ormAdapter) { - throw new Error('No default ORM adapter was defined in supplied in config.ormAdapter!'); - } - return `orm-adapter:${ this.config.ormAdapter }`; - } - }); + return this.lookups.get(parsedName.fullName); } /** - * Lookup a serializer. Falls back to the application serializer if not found. + * Lookup all modules of a specific type in the container. Returns an object of all the modules + * keyed by their module path (i.e. `role:employees/manager` would be found under + * `lookupAll('role')['employees/manager']` */ - private lookupSerializer(parsedName: ParsedName): Serializer { - return this._lookupOther(parsedName, { - fallback: 'serializer:application' + public lookupAll(type: string): { [modulePath: string]: any } { + let resolverResultsets = this.resolvers.map((resolver) => { + return resolver.retrieveAll(type); + }); + let mergedResultset = <{ [modulePath: string]: any }>(assign)(...resolverResultsets.reverse()); + return mapValues(mergedResultset, (rawResolvedObject, modulePath) => { + return this.lookup(`${ type }:${ modulePath }`); }); } /** - * Take the supplied name which can come in several forms, and normalize it. + * Create a local clone of a container entry, which is what will be cached / handed back to the + * consuming application. This avoids any cross-contamination between multiple containers due to + * Node require's caching behavior. */ - private parseName(name: string): ParsedName { - let [ type, modulePath ] = name.split(':'); - if (modulePath === undefined || modulePath === 'undefined') { - throw new Error(`You tried to look up a ${ type } called undefined - did you pass in a variable that doesn't have the expected value?`); + private createLocalClone(object: any) { + // For most types in JavaScript, cloning is simple. But functions are weird - you can't simply + // clone them, since the clone would not be callable. You need to create a wrapper function that + // invokes the original. Plus, in case the function is actually a class constructor, you need to + // clone the prototype as well. One shortcoming here is that the produced function doesn't have + // the correct arity. + if (typeof object === 'function') { + let original = object; + function Containerized() { + return original.apply(this, arguments); + } + Containerized.prototype = Object.assign(Object.create(Object.getPrototypeOf(original.prototype)), original.prototype); + return Containerized; + // Just return primitive values - passing through the function effectively clones them + } else if (!isObject(object)) { + return object; + // For objects, create a new object that shares our source object's prototype. Then copy over + // all the owned properties. From the outside, the result should be an identical object. + } else { + return Object.assign(Object.create(Object.getPrototypeOf(object)), object); } - return { - fullName: name, - type, - modulePath, - moduleName: camelCase(modulePath) - }; } /** @@ -207,21 +221,22 @@ export default class Container extends DenaliObject { * when a lookup of that type fails). */ availableForType(type: string): string[] { - return Object.keys(this._registry).filter((key) => { - return key.split(':')[0] === type; - }).map((key) => { - return key.split(':')[1]; - }); + return this.lookupAll(type).keys(); } } - /** - * If the value is a function, execute it and return the value, otherwise, return the value itself. + * Take the supplied name which can come in several forms, and normalize it. */ -function result(value: any): any { - if (typeof value === 'function') { - return value(); +export function parseName(name: string): ParsedName { + let [ type, modulePath ] = name.split(':'); + if (modulePath === undefined || modulePath === 'undefined') { + throw new Error(`You tried to look up a ${ type } called undefined - did you pass in a variable that doesn't have the expected value?`); } - return value; + return { + fullName: name, + type, + modulePath, + moduleName: camelCase(modulePath) + }; } diff --git a/lib/runtime/resolver.ts b/lib/runtime/resolver.ts new file mode 100644 index 00000000..cb1f7aff --- /dev/null +++ b/lib/runtime/resolver.ts @@ -0,0 +1,144 @@ +import { + camelCase, + upperFirst, + omitBy +} from 'lodash'; +import * as path from 'path'; +import { parseName, ParsedName, ContainerOptions } from './container'; +import * as tryRequire from 'try-require'; +import requireDir from '../utils/require-dir'; + +interface RetrieveMethod { + (parsedName: ParsedName): any; +} + +export interface RetrieveAllMethod { + (type: string): { [modulePath: string]: any }; +} + +export type Registry = Map; + +export default class Resolver { + + [key: string]: any; + + /** + * The root directory for this resolver to start from when searching for files + */ + root: string; + + /** + * The internal cache of available references + */ + private registry: Registry = new Map(); + + constructor(root: string) { + this.root = root; + } + + /** + * Manually add a member to this resolver. Manually registered members take precedence over any + * retrieved from the filesystem. + */ + public register(name: string, value: any) { + this.registry.set(parseName(name).fullName, value); + } + + /** + * Fetch the member matching the given parsedName. First checks for any manually registered + * members, then falls back to type specific retrieve methods that typically find the matching + * file on the filesystem. + */ + public retrieve(parsedName: ParsedName | string) { + if (typeof parsedName === 'string') { + parsedName = parseName(parsedName); + } + if (this.registry.has(parsedName.fullName)) { + return this.registry.get(parsedName.fullName); + } + let retrieveMethod = this[`retrieve${ camelCase(upperFirst(parsedName.type)) }`]; + if (!retrieveMethod) { + retrieveMethod = this.retrieveOther; + } + let result = retrieveMethod.call(this, parsedName); + return result && result.default || result; + } + + /** + * Unknown types are assumed to exist underneath the `app/` folder + */ + protected retrieveOther(parsedName: ParsedName) { + return tryRequire(path.join(this.root, 'app', parsedName.type, parsedName.modulePath)); + } + + /** + * App files are found in `app/*` + */ + protected retrieveApp(parsedName: ParsedName) { + return tryRequire(path.join(this.root, 'app', parsedName.modulePath)); + } + + /** + * Config files are found in `config/` + */ + protected retrieveConfig(parsedName: ParsedName) { + return tryRequire(path.join(this.root, 'config', parsedName.modulePath)); + } + + /** + * Initializer files are found in `config/initializers/` + */ + protected retrieveInitializer(parsedName: ParsedName) { + return tryRequire(path.join(this.root, 'config', 'initializers', parsedName.modulePath)); + } + + /** + * Retrieve all the members for a given type. First checks for all manual registrations matching + * that type, then retrieves all members for that type (typically from the filesystem). + */ + public retrieveAll(type: string) { + let manualRegistrations: { [modulePath: string]: any } = {}; + this.registry.forEach((entry, modulePath) => { + if (parseName(modulePath).type === type) { + manualRegistrations[modulePath] = entry; + } + }); + let retrieveMethod = this[`retrieve${ camelCase(upperFirst(type)) }`]; + if (!retrieveMethod) { + retrieveMethod = this.retrieveAllOther; + } + let resolvedMembers = <{ [modulePath: string]: any }>retrieveMethod.call(this, type); + return Object.assign(resolvedMembers, manualRegistrations); + } + + /** + * Unknown types are assumed to exist in the `app/` folder + */ + protected retrieveAllOther(type: string) { + return requireDir(path.join(this.root, 'app', type)); + } + + /** + * App files are found in `app/*` + */ + protected retrieveAllApp(parsedName: ParsedName) { + return requireDir(path.join(this.root, 'app'), { recurse: false }); + } + + /** + * Config files are found in the `config/` folder. Initializers are _not_ included in this group + */ + protected retrieveAllConfig(type: string) { + return omitBy(requireDir(path.join(this.root, 'config')), (mod, modulePath) => { + return modulePath.startsWith('initializers'); + }); + } + + /** + * Initializers files are found in the `config/initializers/` folder + */ + protected retrieveAllInitializer(type: string) { + return requireDir(path.join(this.root, 'config', 'initializers')); + } + +} \ No newline at end of file diff --git a/lib/runtime/router.ts b/lib/runtime/router.ts index 57724a8b..30b9c4c2 100644 --- a/lib/runtime/router.ts +++ b/lib/runtime/router.ts @@ -186,7 +186,7 @@ export default class Router extends DenaliObject implements RouterDSL { if (response.body) { debug(`[${ request.id }]: writing response body`); res.setHeader('Content-type', response.contentType); - if (this.container.config.environment !== 'production') { + if (this.container.lookup('app:main').environment !== 'production') { res.write(JSON.stringify(response.body, null, 2)); } else { res.write(JSON.stringify(response.body)); diff --git a/lib/utils/types.ts b/lib/utils/types.ts new file mode 100644 index 00000000..44548642 --- /dev/null +++ b/lib/utils/types.ts @@ -0,0 +1,9 @@ +export interface Dict { + [key: string]: T; +} + +export interface Constructor { + new (...args: any[]): T +} + +export type POJO = Dict; \ No newline at end of file diff --git a/package.json b/package.json index 168bd203..8846ffca 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ }, "license": "MIT", "dependencies": { + "@glimmer/di": "^0.1.11", "@types/accepts": "^1.3.1", "@types/bluebird": "^3.0.37", "@types/body-parser": "^1.16.0", diff --git a/test/unit/addon-test.ts b/test/unit/addon-test.ts index efe3cf32..1eca547e 100644 --- a/test/unit/addon-test.ts +++ b/test/unit/addon-test.ts @@ -5,10 +5,8 @@ import { Logger, Addon, Container, Service } from 'denali'; test('Addon > #loadApp > Singletons are instantiated', async (t) => { let dir = path.join(__dirname, '..', 'fixtures', 'addon'); let container = new Container(); - let logger = new Logger(); let addon = new Addon({ environment: 'development', - logger, container, dir }); diff --git a/yarn.lock b/yarn.lock index 81614b80..ad9b2d35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,6 +34,10 @@ ansi-styles "^2.2.1" esutils "^2.0.2" +"@glimmer/di@^0.1.11": + version "0.1.11" + resolved "https://registry.yarnpkg.com/@glimmer/di/-/di-0.1.11.tgz#a6878c07a13a2c2c76fcde598a5c97637bfc4280" + "@types/accepts@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.1.tgz#e5959c500fde65e4bd18a78d8b7f9392ccb9aab4"