Reaction API Core
This NPM package provides the ReactionAPICore
class. Use this to build a NodeJS microservice that is compatible with the Reaction Commerce platform, or to build your main Reaction Commerce API if you don't want to start by forking the https://github.com/reactioncommerce/reaction
project.
If you're just looking to run the default Reaction release with all built-in plugins for development, demos, or evaluation, use the released Docker images and the Reaction Development Platform instead.
This NPM package also provides the ReactionTestAPICore
class, which you can use to write automated tests that run GraphQL queries against your API with a real database connection. This class is almost identical to the ReactionAPICore
class, except that no actual GraphQL server is created and various additional methods are available to help with common testing needs.
Table of Contents generated with DocToc
- Installation
- Usage
- Supported Environment Variables
- ReactionAPICore Configuration
- Plugins
- App Context
- Writing Tests Using ReactionTestAPICore
- Developer Certificate of Origin
- License
Installation
npm install @reactioncommerce/api-core graphql
The graphql
package is a required peer dependency.
Usage
Here is example usage assuming Node 12.14.1 with experimental modules enabled.
import { ReactionAPICore } from "@reactioncommerce/api-core";
import registerFooPlugin from "reaction-plugin-foo";
import Logger from "@reactioncommerce/logger";
import packageJson from "../package.json";
const api = new ReactionAPICore({
// See "ReactionAPICore Configuration" section. Many options are also configured
// by environment variables. See "Supported Environment Variables" section.
serveStaticPaths: ["public"],
version: packageJson.version
});
async function run() {
// Call this once per plugin.
await api.registerPlugin({
// Plugin config. See "Plugin Configuration" section
});
// Plugin packages export a function that calls `api.registerPlugin` for you, so
// for those you just pass `api` to the function.
await registerFooPlugin(api);
// Or you can register multiple at once, passing in either a function or a
// registration object for each.
await api.registerPlugins({
foo: registerFooPlugin,
another: {
// some plugin config
}
});
await api.start();
}
run().catch((error) => {
Logger.error(error);
process.exit(1);
});
Alternatively, you can keep your plugin list in a JSON file:
import { importPluginsJSONFile, ReactionAPICore } from "@reactioncommerce/api-core";
import Logger from "@reactioncommerce/logger";
import authorizationPlugin from "@reactioncommerce/reaction-plugin-authorization";
import packageJson from "../package.json";
const api = new ReactionAPICore({
// See "ReactionAPICore Configuration" section. Many options are also configured
// by environment variables. See "Supported Environment Variables" section.
serveStaticPaths: ["public"],
version: packageJson.version
});
async function run() {
// An optional function allows you to transform the list from the
// JSON (add, swap, or remove plugins) before it attempts to load the
// plugin modules.
const plugins = await importPluginsJSONFile("./plugins.json", (pluginList) => {
pluginList.authorization = authorizationPlugin;
return pluginList;
});
await api.registerPlugins(plugins);
await api.start();
}
run().catch((error) => {
Logger.error(error);
process.exit(1);
});
Sample plugins.json
file:
{
"accounts": "./core-services/account/index.js",
"address": "./core-services/address/index.js",
"authentication": "@reactioncommerce/plugin-authentication",
"authorization": "@reactioncommerce/plugin-simple-authorization"
}
All relative paths are assumed to be relative to the JSON file and are assumed to be an ES module. All packages are assumed to export the plugin registration function as their default ES module export.
Included Features
Most Reaction API features are released as plugins, but even if you never register any plugins and run your API, you'll get the following core features automatically:
- A minimal GraphQL API running at
/graphql
onROOT_URL
, with aping
query, anecho
mutation, and atick
subscription for testing. - Commonly used core GraphQL types such as
Date
,DateTime
,Money
, andRate
, as well as types related to Relay-style pagination - A static file server if you passed in the
serveStaticPaths
option - A connection to MongoDB, with improved connection retry, if the
MONGO_URL
environment variable or themongoDb
option was provided - Access to an Express app at
api.expressApp
Supported Environment Variables
The following environment variables are picked up automatically by the ReactionAPICore
instance.
{
BODY_PARSER_SIZE_LIMIT: bodyParserValidator({
default: 5 * 1000000 // value in bytes = 5mb
}),
GRAPHQL_INTROSPECTION_ENABLED: bool({ default: false, devDefault: true }),
GRAPHQL_PLAYGROUND_ENABLED: bool({ default: false, devDefault: true }),
MONGO_URL: str({
devDefault: "mongodb://localhost:27017/reaction",
desc: "A valid MongoDB connection string URI, ending with the database name",
example: "mongodb://localhost:27017/reaction"
}),
PORT: num({
default: 3000,
desc: "The port on which the API server should listen",
example: "8000"
}),
REACTION_LOG_LEVEL: str({
choices: ["FATAL", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"],
default: "WARN",
devDefault: "DEBUG",
desc: "Determines how much logging you see. The options, from least to most logging, are FATAL, ERROR, WARN, INFO, DEBUG, TRACE. See: https://github.com/trentm/node-bunyan#levels",
example: "ERROR"
}),
REACTION_APOLLO_FEDERATION_ENABLED: bool({
default: false,
desc: "Set this to true if you need Apollo Federation support."
}),
REACTION_GRAPHQL_SUBSCRIPTIONS_ENABLED: bool({
default: true,
desc: "Set this to false if you do not need GraphQL subscription support"
}),
REACTION_SHOULD_INIT_REPLICA_SET: bool({
default: false,
devDefault: true,
desc: "Automatically initialize a replica set for the MongoDB instance. Set this to 'true' when running the app for development or tests."
}),
ROOT_URL: str({
devDefault: "http://localhost:3000",
desc: "The protocol, domain, and port portion of the URL, to which relative paths will be appended. " +
"This is used when full URLs are generated for things such as emails and notifications, so it must be publicly accessible.",
example: "https://shop.mydomain.com"
})
}
ReactionAPICore Configuration
The following options can be passed in the first argument of ReactionAPICore
when initializing an instance.
- appEvents: See the "Providing a Custom appEvents Implementation" section.
- httpServer: An HTTP server for GraphQL subscription websocket handlers. In most cases you should omit this and let
ReactionAPICore
create one for you. - mongodb: Optionally pass in the
mongodb
reference fromimport mongodb from "mongodb";
. In most cases you should omit this and letReactionAPICore
use its own reference, but if you need to ensure a specific version is used, this allows you to do that. - serveStaticPaths: An optional array of paths (relative to your project root) that should be served as static files. Each of these is passed to express.static(). If you need more control, you can access
api.expressApp
directly. - rootUrl: Items such as
api.rootUrl
,context.rootUrl
, andcontext.getAbsoluteUrl
are set based on this. If not provided, falls back toROOT_URL
environment variable. - version: Pass any valid version string. This is available to plugins as
api.version
orcontext.appVersion
. This allows you to version your API as a whole (with all plugins installed and configured), which is different from the version of thisapi-core
package. Use this for whatever purpose you want, but it is not required and will benull
if you don't set a version.
Plugins
The Reaction API server has a plugin system that allows code to be broken into small packages. Plugins can register functions, configuration, and GraphQL schemas and resolvers. The sum of everything registered by every plugin is your Reaction API.
In some cases a plugin has plugins of its own or has other external components that you also need to install.
A plugin need not be in a separate package. You can simply call api.registerPlugin
, passing in any valid configuration. This may be fine for prototyping and quick tests, but we highly recommend that all plugins be published as separate NPM packages, which allows you to track and manage dependencies more easily. This does not necessarily mean they need to be published to NPM, but they must be something you can add to package.json
and NPM will know how to install it. For example, a private GitHub repo will work.
If you publish a plugin as an NPM package, the default package export should be an async function that accepts api
as the first argument and calls api.registerPlugin
with all necessary configuration options.
Plugin Configuration
Whether you are calling api.registerPlugin
directly in an API project or within a plugin package, you must pass in an object that includes everything the plugin wants to make available to the core API or other API plugins. This registerPlugin
object has a specific structure.
The two keys that every plugin will include are name
and label
. name
must be unique, cannot contain spaces, and identifies your plugin; label
is the human-readable version of your plugin name, for showing in UIs.
Beyond name
and label
, the following standard keys can be included in your registerPlugin
object and are described in more detail below:
auth
collections
contextAdditions
expressMiddleware
functionsByType
graphQL
mutations
queries
auth
Plugins can pass functions in an auth
object, which are then used to add permission and account information to context
for each API request.
auth: {
accountByUserId,
permissionsByUserId
}
accountByUserId
: An async function with signature(context, userId)
that must return an account document for the givenuserId
, ornull
if one cannot be found. This will be used to setcontext.account
andcontext.accountId
.permissionsByUserId
: An async function with signature(context, userId)
that must return an array of permissions for the givenuserId
, ornull
if no permission list can be generated. This is available for each API request ascontext.userPermissions
.
See also the "getHasPermissionFunctionForUser" functionsByType.
collections
To create any non-core MongoDB collection that a plugin needs, use the collections
option:
collections: {
MyCustomCollection: {
name: "MyCustomCollection"
}
}
The collections
object key is where you will access this collection on context.collections
, and name
is the collection name in MongoDB. We recommend you make these the same if you can.
The example above will make context.collections.MyCustomCollection
available in all query and mutation functions, and all functions that receive context
, such as startup functions. Note MongoDB may not actually create the collection until the first time you insert into it.
You can optionally add indexes for your MongoDB collection:
collections: {
MyCustomCollection: {
name: "MyCustomCollection",
indexes: [
[{ referenceId: 1 }, { unique: true }]
]
}
}
Each item in the indexes
array is an array of arguments that will be passed to the Mongo createIndex
function. The background
option is always set to true
so you need not include that.
There is also experimental support for defining validation options. Add validator
, validationLevel
, or validationAction
options and they will be passed along to the MongoDB library. Refer to their createCollection documentation.
There is also a convenience syntax that allows you to pass a JSONSchema directly. This:
collections: {
MyCustomCollection: {
name: "MyCustomCollection",
jsonSchema: someJsonSchema
}
}
Is shorthand for this:
collections: {
MyCustomCollection: {
name: "MyCustomCollection",
validator: {
$jsonSchema: someJsonSchema
}
}
}
NOTE: Registering your collections is not required, and in fact it is probably best to NOT register them unless you need to allow other plugins to access your data directly. As long as you do not register them, they remain a private implementation detail that you are free to change without breaking other plugins.
contextAdditions
A plugin can add properties to context using the contextAdditions
option. They are added before any preStartup
or startup
functions are run.
contextAdditions: {
something: "wicked"
}
// in startup fn or anywhere you have context
console.log(context.something); // "wicked"
expressMiddleware
Plugins can register Express middleware using the expressMiddleware
option:
expressMiddleware: [
{
route: "graphql",
stage: "authenticate",
fn: tokenMiddleware
}
]
For now, only the "graphql" route is supported, and the following stages are supported in this order:
first
before-authenticate
authenticate
before-response
An authenticate
middleware function should do something like look up the user by the Authorization header, and either set request.user
or send a 401 response if the token is invalid. It should not require a token.
The first
middleware stage can be used for loggers or anything else that needs to be first in the middleware list. before-response
middleware will have the user available if there is one, and is called before the Apollo GraphQL middleware.
A middleware function is passed context
and must return the Express middleware handler function, which must call next()
or send a response.
functionsByType
The functionsByType
object is a map of function types to arrays of functions of that type. This pattern can be used by any plugin to allow any other plugin to register certain types of functions for plugin points.
Documentation for individual plugins will tell you how to use this for that plugin, but there are also a few core types that any plugin might want to use:
- createDataLoaders
- getHasPermissionFunctionForUser
- preStartup
- registerPluginHandler
- startup
- shutdown
Look at built-in plugins for examples of these, and read How To: Share Code Between API Plugins for more information.
graphQL
Use the graphQL
object to register schemas and resolvers that extend the core GraphQL API.
More information:
- How To: Create a new GraphQL mutation
- How To: Create a new GraphQL query
- How To: Extend GraphQL to add a field
- How To: Extend GraphQL with Remote Schema Delegation
mutations and queries
The mutations
and queries
objects are maps of functions that are extended onto context.mutations
and context.queries
. These may be functions that are called from GraphQL resolvers of a similar name, or functions that are intended to be called only by other plugin code. Functions that modify data should be registered as mutations
and all other functions should be registered as queries
.
App Context
Apollo Server passes a context
object to every GraphQL resolver. By convention, plugins pass this down as the first argument to all internal query, mutation, and util functions, too. We refer to this as the app context, and many different things live on it. It is the primary way that functions and other information are shared among plugins.
There are two types of properties on the app context: instance properties and request properties. Most properties are instance properties; they are set when you initialize a ReactionAPICore
instance or when you register a plugin, and they do not change while the API is running. A few other properties are added to the context at the start of every request that comes in, and these are the request properties.
Instance properties:
app
: TheReactionAPICore
instance that owns this context. (Note thatapi.context.app
is a circular reference.)appEvents
: A simple event mechanism withon
andemit
properties. This allows event-based communication among plugins. Unlike Node's built-inEventEmitter
,appEvents.on
functions may return Promises andappEvents.emit
may be awaited in situations where you want to be sure all event listeners have handled the event before continuing.appVersion
: A string set to whatever theversion
option is when creating theReactionAPICore
instancecollections
: An object with references to MongoDB collections, built by any plugins that register collections. Allows direct access to collections from various plugins even if only one plugin "owns" that collection.getInternalContext
: A function that takes no arguments and returns a context with all authentication and authorization mocked to allow anything. Useful when you need to pass down context to another query or mutation but circumvent its normal authorization checks.getFunctionsOfType
: A function that takes one argument, a type string, and returns an array of all functions that were registered with that type. This is a simple way that plugins can share functions with other plugins for specific purposesmutations
andqueries
: These objects have references to all functions that plugins have registered in their ownmutations
andqueries
objects, when callingregisterPlugin
. If multiple plugins have mutations or queries with the same name, the last plugin to be registered will win (unlikegetFunctionsOfType
where multiple functions may be registered).rootUrl
: The root URL string, fromrootUrl
instance option orROOT_URL
environment variablegetAbsoluteUrl
: A function that is bound torootUrl
, for easy conversion of a relative URL to one that is absolute and begins withrootUrl
.
There may be additional instance properties if any plugins have added them using the contextAdditions
option for registerPlugin
. Refer to documentation for individual plugins.
Request properties:
user
: The User document for the user who made the request. This is set to the Expressrequest.user
property, which any plugin can set by registering request middleware.userId
: Ifuser
is set, this will be theuser._id
, for convenience.account
: Ifcontext.auth.accountByUserId
is a function andcontext.user
is set,accountByUserId
will be called for the request, and the return value will be available ascontext.account
. Theuser
is a generic authentication document for OAuth, whereasaccount
is a Reaction-specific concept where additional information about each user is tracked.accountId
: Ifaccount
is set, this will be theaccount._id
, for convenience.userPermissions
: Ifcontext.auth.permissionsByUserId
is a function, it will be called with(context, userId)
arguments, and the return value is available ascontext.userPermissions
.userHasPermission
: A function that you can call to determine whether a user has needed permissions. It calls any functions registered as typegetHasPermissionFunctionForUser
, passing incontext
, and then calls the function returned by each. Returnstrue
only if every registered function returnstrue
.validatePermissions
: CallsuserHasPermission
and throws an Access Denied error if it returnsfalse
.requestHeaders
: An object with all HTTP headers for the current request on it, except withAuthorization
andCookie
headers removed for security.
You can always access the app context on api.context
if you have a reference to the api
instance, but this will never have any request properties set because there is no request happening.
Providing a Custom appEvents Implementation
context.appEvents
is available to all plugins and is used for communication among plugins. The built-in implementation of this is not highly optimized, but you may provide your own implementation to meet your needs.
To override the built-in appEvents
with your own, pass appEvents
option when constructing your API instance. The provided object must have 4 function properties named emit
, on
, stop
, and resume
. Here's an example:
const appEvents = {
async emit(name, ...args) {},
on(name, func) {},
stop() {},
resume() {}
};
const api = new ReactionAPICore({ appEvents });
stop
should stop emission andresume
should resume emission. Neither takes any arguments.emit
:emit
may or may not return a Promise, but either way the final returned value must beundefined
- When emission is stopped,
emit
should do nothing - When emission is not stopped,
emit
should call each handler registered byon
one by one. If a handler returns a Promise, wait until it resolves or rejects before calling the next handler.emit
itself should not resolve or reject until all handlers have been called and have resolved or rejected. - Any additional
args
passed toemit
should be passed along to each handler function.
on
registers a handler function to be called byemit
.- Handlers are called only after they are registered. Any events emitted prior to registering a handler are never handled by that handler.
Writing Tests Using ReactionTestAPICore
Refer to Writing Jest Integration Tests.
Developer Certificate of Origin
We use the Developer Certificate of Origin (DCO) in lieu of a Contributor License Agreement for all contributions to Reaction Commerce open source projects. We request that contributors agree to the terms of the DCO and indicate that agreement by signing-off all commits made to Reaction Commerce projects by adding a line with your name and email address to every Git commit message contributed:
Signed-off-by: Jane Doe <jane.doe@example.com>
You can sign-off your commit automatically with Git by using git commit -s
if you have your user.name
and user.email
set as part of your Git configuration.
We ask that you use your real full name (please no anonymous contributions or pseudonyms) and a real email address. By signing-off your commit you are certifying that you have the right to submit it under the GNU GPLv3 License.
We use the Probot DCO GitHub app to check for DCO sign-offs of every commit.
If you forget to sign-off your commits, the DCO bot will remind you and give you detailed instructions for how to amend your commits to add a signature.
License
This Reaction package is GNU GPLv3 Licensed