/** @namespace GQLBaseEnv */
// @flow
import Path from 'path'
import fs from 'fs'
import { Deferred, joinLines } from './utils'
import { typeOf } from './types'
import { SyntaxTree } from './SyntaxTree'
import { GraphQLObjectType } from 'graphql'
import { IDLFileHandler } from './IDLFileHandler'
import { merge } from 'lodash'
import EventEmitter from 'events'
/* Internal implementation to detect the existence of proxies. When present
* additional functionality is enabled. Proxies are native in Node >= 6 */
const hasProxy = typeof global.Proxy !== 'undefined';
/* Internal Symbol referring to real accessor to GQLBase model object */
const _MODEL_KEY = Symbol.for('data-model-contents-value');
/* Internal Symbol referring to the static object containing a proxy handler */
const _PROXY_HANDLER = Symbol.for('internal-base-proxy-handler')
/**
* Simple function to check if a supplied key matches a string of your
* choosing and that string is not a defined property on the instance
* passed to the check.
*
* @method GQLBaseEnv~notDefined
* @memberof GQLBaseEnv
* @since 2.5.0
*
* @param {string} keyToTest a String denoting the property you wish to test
* @param {mixed} keySupplied a value, coerced `toString()`, to compare to
* `keyToTest`
* @param {mixed} instance an object instance to check `hasOwnProperty` on for
* the `keyToTest` supplied.
* @return {Boolean} true if the property matches the supplied key and that
* property is not an ownedProperty of the instance supplied.
*/
export function notDefined(
keyToTest: string,
keySupplied: Object | string,
instance: Object
) {
return (
new RegExp("^" + keyToTest + "$").test(keySupplied.toString())
&& !instance.hasOwnProperty(keyToTest)
);
}
/**
* A `Symbol` used as a key to store the backing model data. Designed as a
* way to separate model data and GraphQL property accessors into logical bits.
*
* @type {Symbol}
* @memberof GQLBaseEnv
* @const
*/
export const MODEL_KEY = Symbol.for('data-model-contents-key');
/**
* A `Symbol` used as a key to store the request data for an instance of the
* GQLBase object in question.
*
* @type {Symbol}
* @const
* @inner
* @memberof GQLBaseEnv
*/
export const REQ_DATA_KEY = Symbol.for('request-data-object-key');
/**
* A nameless Symbol for use as a key to the internal decorator storage
*
* @type {Symbol}
* @const
* @inner
* @memberof GQLBaseEnv
*/
export const META_KEY = Symbol();
/**
* All GraphQL Type objects used in this system are assumed to have extended
* from this class. An instance of this class can be used to wrap an existing
* structure if you have one.
*
* @class GQLBase
*/
export class GQLBase extends EventEmitter {
fileHandler: ?IDLFileHandler;
/**
* Request data is passed to this object when constructed. Typically these
* objects, and their children, are instantiated by its own static MUTATORS
* and RESOLVERS. They should contain request specific state if any is to
* be shared.
*
* These can be considered request specific controllers for the object in
* question. The base class takes a single object which should contain all
* the HTTP/S request data and the graphQLParams is provided as the object
* { query, variables, operationName, raw }.
*
* When used with express-graphql, the requestData object has the format
* { req, res, gql } where
* • req is an Express 4.x request object
* • res is an Express 4.x response object
* • gql is the graphQLParams object in the format of
* { query, variables, operationName, raw }
* See https://github.com/graphql/express-graphql for more info
*
* @memberof GQLBase
* @method ⎆⠀constructor
* @constructor
*
* @param {mixed} modelData this, typically an object, although anything
* really is supported, represents the model data for our GraphQL object
* instance.
* @param {Object} requestData see description above
*/
constructor(modelData: Object = {}, requestData: ?Object = null) {
super();
const Class = this.constructor;
GQLBase.setupModel(this);
this.setModel(modelData);
this.requestData = requestData || {};
this.fileHandler = new IDLFileHandler(this.constructor);
// @ComputedType
return hasProxy ? new Proxy(this, GQLBase[_PROXY_HANDLER]) : this;
}
/**
* Getter for the internally stored model data. The contents of this
* object are abstracted away behind a `Symbol` key to prevent collision
* between the underlying model and any GraphQL Object Definition properties.
*
* @instance
* @memberof GQLBase
* @method ⌾⠀getModel
* @since 2.5
*
* @param {Object} value any object you wish to use as a data store
*/
getModel() {
// @ComputedType
return this[MODEL_KEY];
}
/**
* Setter for the internally stored model data. The contents of this
* object are abstracted away behind a `Symbol` key to prevent collision
* between the underlying model and any GraphQL Object Definition properties.
*
* @instance
* @memberof GQLBase
* @method ⌾⠀setModel
* @since 2.5
*
* @param {Object} value any object you wish to use as a data store
*/
setModel(value: Object): GQLBase {
// @ComputedType
this[MODEL_KEY] = value;
return this;
}
/**
* Uses `_.merge()` to modify the internal backing data store for the
* object instance. This is a shortcut for
* `_.merge()(instance[MODEL_KEY], ...extensions)`
*
* @instance
* @memberof GQLBase
* @method ⌾⠀extendModel
* @since 2.5
*
* @param {mixed} extensions n-number of valid `_.merge()` parameters
* @return {GQLBase} this is returned
*/
extendModel(...extensions: Array<mixed>): GQLBase {
// $FlowFixMe
merge(this[MODEL_KEY], ...extensions);
return this;
}
/**
* A getter that retrieves the inner request data object. When used with
* GQLExpressMiddleware, this is an object matching {req, res, gql}.
*
* @instance
* @memberof GQLBase
* @method ⬇︎⠀requestData
*
* @return {Object} an object, usually matching { req, res, gql }
*/
get requestData(): Object | null {
// @ComputedType
return this[REQ_DATA_KEY];
}
/**
* A setter that assigns a value to the inner request data object. When
* used with GQLExpressMiddleware, this is an object matching {req, res, gql}.
*
* @instance
* @memberof GQLBase
* @method ⬆︎⠀requestData
*
* @param {Object} value an object, usually matching { req, res, gql }
*/
set requestData(value: Object): void {
// @ComputedType
this[REQ_DATA_KEY] = value;
}
/**
* Returns the `constructor` name. If invoked as the context, or `this`,
* object of the `toString` method of `Object`'s `prototype`, the resulting
* value will be `[object MyClass]`, given an instance of `MyClass`
*
* @method ⌾⠀[Symbol.toStringTag]
* @memberof ModuleParser
*
* @return {string} the name of the class this is an instance of
* @ComputedType
*/
get [Symbol.toStringTag]() { return this.constructor.name }
/**
* Until such time as the reference implementation of Facebook's GraphQL
* SDL AST parser supports comments, or until we take advantage of Apollo's
* AST parser, this is how comments will be applied to a built schema.
*
* Several constants are defined on the GQLBase object itself, and thereby
* all its subclasses. They pertain to how to define description fields
* for various parts of your GQL implementation.
*
* ```
* // To define a description on the top level class
* [this.DOC_CLASS]: string
*
* // To define a description on a field (getter, function or async function)
* [this.DOC_FIELDS]: {
* fieldName: string
* }
*
* // To define a description on a query, mutation or subscription field
* [this.DOC_QUERIES || this.DOC_MUTATORS || this.DOC_SUBSCRIPTIONS]: {
* fieldName: string
* }
* ```
*
* To make writing code easier, the `joinLines()` template function is
* available so your source code can look nice and neat and your descriptions
* won't get annoying line breaks and spaces as part of that process.
*
* @static
* @memberof GQLBase
* @method apiDocs
*
* @return {Object} an object with various keys and values denoting
* description fields that should be applied to the final schema object
*/
static apiDocs(): Object {
return {
[this.DOC_CLASS]: joinLines`
GQLBase class implementation. GQLBase is the root class used in
graphql-lattice to describe a GraphQLObjectType. If you are reading
this, the person using lattice failed to provide documentation for
their type. :)
`,
[this.DOC_QUERY]: joinLines`
## Welcome to GraphQL Lattice
**Query**
You will want to define a \`DOC_QUERY\` apiDoc comment with something
more meaningful to your particular Schema here.
`,
[this.DOC_MUTATION]: joinLines`
## Welcome to GraphQL Lattice
**Mutation**
You will want to define a \`DOC_MUTATION\` apiDoc comment with
something more meaningful to your particular Schema here.
`,
[this.DOC_SUBSCRIPTION]: joinLines`
## Welcome to GraphQL Lattice
**Subscription**
You will want to define a \`DOC_SUBSCRIPTION\` apiDoc comment with
something more meaningful to your particular Schema here.
`,
[this.DOC_FIELDS]: {
// fieldName: `fieldDescription`,
},
[this.DOC_QUERIES]: {
// queryName: `queryDescription`,
},
[this.DOC_MUTATORS]: {
// mutatorName: `mutatorDescription`
},
[this.DOC_SUBSCRIPTIONS]: {
// subscriptionName: `subscriptionDescription`
}
}
}
/**
* Defined in a base class, this getter should return either a String
* detailing the full IDL schema of a GraphQL handler or one of two
* types of Symbols.
*
* The first Symbol type is the constant `ADJACENT_FILE`. If this Symbol is
* returned, the system assumes that next to the source file in question is
* a file of the same name with a .graphql extension. This file should be
* made of the GraphQL IDL schema definitions for the object types being
* created.
*
* Example:
* ```js
* static get SCHEMA(): string | Symbol {
* return GQLBase.ADJACENT_FILE
* }
* ```
*
* The primary advantage of this approach is allowing an outside editor that
* provides syntax highlighting rather than returning a string from the
* SCHEMA getter.
*
* Alternatively, the static method IDLFilePath can be used to point to an
* alternate location where the GraphQL IDL file resides. The extension can
* also be changed from .graphql to something else if need be using this
* method.
*
* Example:
* ```js
* static get SCHEMA(): string | Symbol {
* return GQLBase.IDLFilePath('/path/to/file', '.idl')
* }
* ```
*
* @instance
* @memberof GQLBase
* @method ⬇︎⠀SCHEMA
* @readonly
* @static
*
* @return {string|Symbol} a valid IDL string or one of the Symbols
* described above.
*
* @see {@link GQLBase#ADJACENT_FILE}
* @see {@link GQLBase#IDLFilePath}
*/
static get SCHEMA(): string | Symbol {
return ''
}
/**
* This method should return a promise that resolves to an object of
* functions matching the names of the mutation operations. These are to be
* injected into the root object when used by `GQLExpressMiddleware`.
*
* @instance
* @memberof GQLBase
* @method ⌾⠀MUTATORS
* @readonly
* @static
*
* @param {Object} requestData typically an object containing three
* properties; {req, res, gql}
* @return {Promise} a promise that resolves to an object; see above for more
* information.
*/
static async MUTATORS(requestData: Object): Promise<Object> {
// define in base class
return {};
}
/**
* This method should return a promise that resolves to an object of
* functions matching the names of the query operations. These are to be
* injected into the root object when used by `GQLExpressMiddleware`.
*
* @instance
* @memberof GQLBase
* @method ⌾⠀RESOLVERS
* @readonly
* @static
*
* @param {Object} requestData typically an object containing three
* properties; {req, res, gql}
* @return {Promise} a promise that resolves to an object; see above for more
* information.
*/
static async RESOLVERS(requestData: Object): Promise<Object> {
// define in base class
return {};
}
/**
* @see {@link GQLBase#SCHEMA}
*
* @memberof GQLBase
* @method ⬇︎⠀ADJACENT_FILE
* @static
* @const
*
* @return {Symbol} the Symbol, when returned from SCHEMA, causes
* the logic to load an IDL Schema from an associated file with a .graphql
* extension and bearing the same name.
*/
static get ADJACENT_FILE(): Symbol {
return Symbol.for('.graphql file located adjacent to source')
}
/**
* Determines the default type targeted by this GQLBase class. Any
* type will technically be valid but only will trigger special behavior
*
* @memberof GQLBase
* @method ⬇︎⠀GQL_TYPE
* @static
* @const
*
* @return {Function} a type, such as `GraphQLObjectType` or
* `GraphQLInterfaceType`
*/
static get GQL_TYPE(): Function {
return GraphQLObjectType;
}
/**
* Creates an appropriate Symbol crafted with the right data for use by
* the IDLFileHandler class below.
*
* @static
* @memberof GQLBase
* @method ⌾⠀IDLFilePath
*
* @param {string} path a path to the IDL containing file
* @param {string} [extension='.graphql'] an extension, including the
* prefixed period, that will be added to the supplied path should it not
* already exist.
* @return Symbol
*
* @see {@link GQLBase#SCHEMA}
*/
static IDLFilePath(path: string, extension: string = '.graphql'): Symbol {
return Symbol.for(`Path ${path} Extension ${extension}`);
}
/**
* A file handler for fetching the IDL schema string from the file system
* for those `GQLBase` extended classes that have indicated to do so by
* returning a `Symbol` for their `SCHEMA` property.
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀handler
*
* @return {IDLFileHandler} instance of IDLFileHandler, created if one does
* not already exist, for fetching the contents from disk.
*/
static get handler(): IDLFileHandler {
const key = Symbol.for(`${IDLFileHandler.name}.${this.name}`);
// @ComputedType
if (!this[key]) {
// @ComputedType
this[key] = new IDLFileHandler(this);
}
// @ComputedType
return this[key];
}
/**
* Returns the module object where your class is created. This needs to be
* defined on your class, as a static getter, in the FILE where you are
* defining your Class definition.
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀module
* @const
*
* @return {Object} the reference to the module object defined and injected
* by node.js' module loading system.
*
* @see https://nodejs.org/api/modules.html
*/
static get module(): Object {
return module;
}
/**
* The internal data model has some custom `EventEmitter` code wrapped
* it here. When the data model is set via `setModel` or by accessing it
* via `instance[MODEL_KEY]`, an event `EVENT_MODEL_SET` is emitted. Any
* listener listening for this event receives an object with two keys
* ```
* {
* model: The actual model being set; changes are persisted
* instance: The GQLBase instance the model is associated with
* }
* ```
*
* Subsequently, the events `EVENT_MODEL_PROP_CHANGE` and
* `EVENT_MODEL_PROP_DELETE` can be listened to if your version of node
* supports Proxy objects. They allow you to be notified whenever your
* model has a property changed or deleted, respectively.
*
* The callback for `change` receives an object with four properties
* ```
* {
* model: The model object the value is being changed on
* old: The old value being replaced; undefined if it is the first time
* key: The property key for the value being changed
* value: The new value being set
* }
* ```
*
* The callback for `delete` receives an object with four properties
* ```
* {
* model: The model object the value is deleted from
* key: The property key for the deleted value
* deleted: The deleted value
* }
* ```
*
* @static
* @memberof GQLBase
* @method ⌾⠀setupModel
*
* @param {GQLBase} instance typically `this` as passed in from a call in
* the constructor
*/
static setupModel(instance: GQLBase) {
const changeHandler: Object = {
/**
* Proxy set() handler. This is where the change events are fired from
*
* @method GQLBase~set
* @param {Object} target the `GQLBase` model object
* @param {string} key the property name
* @param {mixed} value the new property value
*/
set(target, key, value) {
const old = target[key];
target[key] = value;
instance.emit(GQLBase.EVENT_MODEL_PROP_CHANGE, {
model: target,
old,
key,
value
})
},
/**
* Proxy deleteProperty() handler. This is where the delete property
* events are fired from
*
* @method GQLBase~deleteProperty
* @param {Object} target the `GQLBase` model object
* @param {string} key the property name
*/
deleteProperty(target, key) {
const deleted = target[key];
delete target[key];
instance.emit(GQLBase.EVENT_MODEL_PROP_DELETE, {
model: target,
key,
deleted
})
}
}
/**
* 'Publicly' the Symbol for accessing the `GQLBase` model is `MODEL_KEY`.
* In truth it is stored under a Symbol defined in `setupModel` and
* referred to as `_MODEL_KEY` in this code. This is done so a getter and
* setter can be wrapped around the usage of the instance's data model.
*
* When being read, if `Proxy` exists in the node environment and if there
* are any registered `EVENT_MODEL_PROP_CHANGE` or `EVENT_MODEL_PROP_DELETE`
* events, then the returned model is a Proxy around the real model that
* allows us to capture the changes and deletion of keys
*
* When being assigned, the event `EVENT_MODEL_WILL_BE_SET` and the event
* `EVENT_MODEL_HAS_BEEN_SET` are emitted to allow listeners to modify and
* see the final data around the setting of a model object. Both events
* receive an object with two keys
*
* ```
* {
* model: The object being or having been set
* instance: The GQLBase instance receiving the model
* }
* ```
*/
Object.defineProperty(instance, MODEL_KEY, {
get: function() {
let model = this[_MODEL_KEY]
let hasListeners =
this.listenerCount(GQLBase.EVENT_MODEL_PROP_CHANGE) +
this.listenerCount(GQLBase.EVENT_MODEL_PROP_DELETE)
if (hasProxy && hasListeners) {
model = new Proxy(model, changeHandler);
}
return model
},
set: function(model) {
const instance = this;
this.emit(GQLBase.EVENT_MODEL_WILL_BE_SET, { model, instance });
instance[_MODEL_KEY] = model;
this.emit(GQLBase.EVENT_MODEL_HAS_BEEN_SET, { model, instance })
}
});
}
/**
* If ES6 Proxies are supported in your execution environment, all GQLBase
* extended classes are also proxies. By default the internal proxy handler
* provides backwards compatibility with the removal of the default getters
* and setters for the 'model' property as long as you do not define a
* top level 'model' property of your own.
*
* @method ⬇︎⠀[_PROXY_HANDLER]
* @memberof GQLBase
* @static
* @const
* @since 2.5.0
*
* @type {Object}
* @ComputedType
*/
static get [_PROXY_HANDLER]() {
return {
get(target, key, lastResult) {
const model = target[_MODEL_KEY];
// Allow backwards compatibility for 'model' property if one is not
// explicitly defined on your instance.
if (notDefined('model', key, target)) {
// Be sure to use the public MODEL_KEY to ensure events fire
return target[MODEL_KEY];
}
return target[key]
}
}
}
/**
* Applies the same logic as {@link #[Symbol.toStringTag]} but on a static
* scale. So, if you perform `Object.prototype.toString.call(MyClass)`
* the result would be `[object MyClass]`.
*
* @method ⌾⠀[Symbol.toStringTag]
* @memberof ModuleParser
* @static
*
* @return {string} the name of this class
* @ComputedType
*/
static get [Symbol.toStringTag]() { return this.name }
/**
* A constant used to register an event listener for when the internal
* model object is assigned a new value. This event fires before the model
* is set. Changes to the model value at this point will affect the contents
* before the value assignment takes place.
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀EVENT_MODEL_WILL_BE_SET
* @const
*
* @type {string}
*/
static get EVENT_MODEL_WILL_BE_SET() { return 'E: Int. model will be set' }
/**
* A constant used to register an event listener for when the internal
* model object is assigned a new value. This event fires after the model
* is set.
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀EVENT_MODEL_HAS_BEEN_SET
* @const
*
* @type {string}
*/
static get EVENT_MODEL_HAS_BEEN_SET() { return 'E: Int. model has been set' }
/**
* A constant used to register an event listener for when a property of the
* internal model object is set to a new or intial value.
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀EVENT_MODEL_PROP_CHANGE
* @const
*
* @type {string}
*/
static get EVENT_MODEL_PROP_CHANGE() { return 'E: Int. model prop changed' }
/**
* A constant used to register an event listener for when a property of the
* internal model object has been deleted. This event fires after the value
* has been deleted.
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀EVENT_MODEL_PROP_DELETE
* @const
*
* @type {string}
*/
static get EVENT_MODEL_PROP_DELETE() { return 'E: Int. model prop deleted' }
/**
* A constant key used to identify a comment for a class description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_CLASS
* @const
*
* @type {string}
*/
static get DOC_CLASS() { return 'class' }
/**
* A constant key used to identify a comment for a type field description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_FIELDS
* @const
*
* @type {string}
*/
static get DOC_FIELDS() { return 'fields' }
/**
* A constant key used to identify a comment for the top level query
* description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_QUERY
* @const
*
* @type {string}
*/
static get DOC_QUERY() { return 'query' }
/**
* A constant key used to identify a comment for a query description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_QUERIES
* @const
*
* @type {string}
*/
static get DOC_QUERIES() { return 'queries' }
/**
* A constant key used to identify a comment for the top level mutation
* description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_MUTATION
* @const
*
* @type {string}
*/
static get DOC_MUTATION() { return 'mutation' }
/**
* A constant key used to identify a comment for a mutator description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_MUTATORS
* @const
*
* @type {string}
*/
static get DOC_MUTATORS() { return 'mutators' }
/**
* A constant key used to identify a comment for the top level subscription
* description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_SUBSCRIPTION
* @const
*
* @type {string}
*/
static get DOC_SUBSCRIPTION() { return 'subscription' }
/**
* A constant key used to identify a comment for a subscription description
*
* @static
* @memberof GQLBase
* @method ⬇︎⠀DOC_SUBSCRIPTIONS
* @const
*
* @type {string}
*/
static get DOC_SUBSCRIPTIONS() { return 'subscriptions' }
/**
* A shortcut to the utils/joinLines function to make it easier to get
* the tools to write docs for your types in a friendly fashion.
*
* @memberof GQLBase
* @method ⬇︎⠀joinLines
* @static
* @const
*
* @type {Function}
*/
static get joinLines(): Function { return joinLines }
/**
* An simple pass-thru method for fetching a types merged root object.
*
* @method ⌾⠀getMergedRoot
* @memberof GQLBase
* @static
*
* @param {Object} requestData an object containing the request data such as
* request, response or graphql context info that should be passed along to
* each of the resolver creators
* @return {Object} the merged root object with all the query, mutation and
* subscription resolvers defined and created within.
*/
static async getMergedRoot(
requestData: Object,
separateByType: boolean = false
): Object {
const root = {};
const Class = this;
let _ = {
// $FlowFixMe
resolvers: Class[META_KEY].resolvers || [],
// $FlowFixMe
mutators: Class[META_KEY].mutators || [],
// $FlowFixMe
subscriptors: Class[META_KEY].subscriptors || []
}
let convert = f => {return { [f.name]: f.bind(Class, requestData) }}
let reduce = (p, c) => merge(p, c)
_.resolvers = _.resolvers.map(convert).reduce(reduce, {})
_.mutators = _.mutators.map(convert).reduce(reduce, {})
_.subscriptors = _.subscriptors.map(convert).reduce(reduce, {})
if (separateByType) {
// Apollo wants all the resolvers to grouped by top level type.
// The field resolvers aren't an issue in Lattice defined types
// but the root types do need to be sorted; so let's do that here
merge(
root,
{ Query: await Class.RESOLVERS(requestData) },
{ Mutation: await Class.MUTATORS(requestData) },
{ Query: _.resolvers },
{ Mutation: _.mutators },
{ Subscription: _.subscriptors }
);
// When using lattice with apollo server, it is quite particular about
// empty Query, Mutation or Subscription resolver maps.
if (!Object.keys(root.Query).length) delete root.Query
if (!Object.keys(root.Mutation).length) delete root.Mutation
if (!Object.keys(root.Subscription).length) delete root.Subscription
}
else {
merge(
root,
await Class.RESOLVERS(requestData),
await Class.MUTATORS(requestData),
_.resolvers,
_.mutators,
_.subscriptors
);
}
return root;
}
/**
* An object used to store data used by decorators and other internal
* proccesses.
* @ComputedType
*/
static get [META_KEY]() {
let storage = this[Symbol.for(this.name)]
if (!storage) {
storage = (this[Symbol.for(this.name)] = {})
}
return storage;
}
}
export default GQLBase;