diff --git a/docs/jsdoc-style-guide.md b/docs/jsdoc-style-guide.md new file mode 100644 index 0000000..8604867 --- /dev/null +++ b/docs/jsdoc-style-guide.md @@ -0,0 +1,35 @@ +# JSDoc Style Guide + +## The basics + +Document every JavaScript function by adding [JSDoc](http://usejsdoc.org/) comments above the function definition with the following tags: + +### required +- `@summary` can use Markdown here +- `@param` {type} name description, use `[]` square brackets around param for optional params +- `@return` {type} name description, or `@return {undefined}` + +### optional +- `@async` +- `@private` +- `@default` +- `@deprecated` - since version number +- `@since` - version number +- `@todo` - any TODO notes here +- `@ignore` - if you don't want the function to output docs +- `@author` - to indicate third-party method authors +- `@see` - link to relevant third-party documentation + +## Example + +```js +/** + * @summary Import all plugins listed in a JSON file. Relative paths are assumed + * to be relative to the JSON file. This does NOT register the plugins. It builds + * a valid `plugins` object which you can then pass to `api.registerPlugins`. + * @param {String} pluginsFile An absolute or relative file path for a JSON file. + * @param {Function} [transformPlugins] A function that takes the loaded plugins object and + * may return an altered plugins object. + * @returns {Promise} Plugins object suitable for `api.registerPlugins` + */ +``` diff --git a/guides/developers-guide/core/developing-graphql.md b/guides/developers-guide/core/developing-graphql.md new file mode 100644 index 0000000..440d122 --- /dev/null +++ b/guides/developers-guide/core/developing-graphql.md @@ -0,0 +1,176 @@ +# Developing the GraphQL API + +## Extending and Modifying the GraphQL API + +The GraphQL schema is written in multiple `.graphql` files, which contain type definitions in the GraphQL schema language. These files live in the plugins to which they relate, in a `schemas` folder. Refer to one of the How To articles: + +- [How To: Create a new GraphQL query](../../../how-tos/create-graphql-query.md) +- [How To: Create a new GraphQL mutation](../../../how-tos/create-graphql-mutation.md) + +## Documenting a GraphQL Schema + +Every type, input type, enum, query, mutation, and field must have documentation. Add a description using a string literal immediately above the thing you are documenting in the `.graphql` file. This is the official API documentation, so take the time to make it clear, well-formatted, and with no spelling or grammar errors. + +Tips: +- For `_id`, `clientMutationId` and anything else that appears in multiple places, copy the documentation from elsewhere so that everything matches. +- There is often a `type` and a related `input` type. If the field names match, their documentation should also be identical or very similar. + +External references: +- [GraphQL "description" spec](https://facebook.github.io/graphql/draft/#sec-Documentation) +- [Apollo Server: Documenting Your Schema](https://www.apollographql.com/docs/apollo-server/schema/schema/#documentation-strings) + +## Where Resolvers are Defined + +Every plugin that extends the GraphQL schema should also have a `resolvers` folder, which should have an `index.js` file in which the default export is the full `resolvers` object. This object should be built by importing other files and folders in that directory, such that the folder and file tree looks the same as the `resolvers` object tree. + +For example, there are typically folders named `Mutation` and `Query`. In the `accounts` plugin there is also an `Account` folder, where resolvers for that type live. You may choose either folders or single files, depending on how many resolvers there are and how complex they are. + +The `resolvers` object for each plugin is deep merged with all the `resolvers` exported by all the other plugins, and the result is the full resolver function tree. + +## Resolver Mutations and Queries vs. Plugin Mutations and Queries + +The path a GraphQL query or mutation takes is first to a resolver function, which then calls a query or mutation function provided by one of the plugins. It’s important to understand what happens in each. + +The resolver function: +- Lives in `resolvers` in a plugin folder +- Returns a Promise (is async) +- Transforms IDs (see [IDs in GraphQL](#ids-in-graphql)) and data structures (where they don’t match internal data structures) +- May pull things from the GraphQL context to pass to the plugin function +- May throw a `ReactionError` if anything goes wrong +- Includes `clientMutationId` in the response (for mutations only) + +The plugin function: +- Lives in `queries` or `mutations` in a plugin folder +- Is available on the GraphQL context in `context.queries` or `context.mutations`, and as such can be called by code elsewhere in the app +- Returns a Promise (is async) +- Does all permission checks +- May throw a `ReactionError` if anything goes wrong +- Performs the actual database mutations or queries + +TIP: If you’re confused about where to draw the line, generally resolvers are for _transforming_ data (both inbound and outbound) +while plugin functions read or write data and perform business logic. + +## The Endpoint + +The GraphQL server and `/graphql` endpoint is configured and returned by the `createApolloServer` function, which is called from the `ReactionAPI` class instance. + +`createApolloServer` does pretty standard configuration of an Express app using `apollo-server-express`. The main things it does are: +- Checks the access token using Express middleware +- Builds the `context` object that’s available in all resolver functions. See [The Reaction GraphQL Context](#the-reaction-graphql-context) +- Formats the `errors` array that is returned to clients, to make errors as helpful as possible +- Provides the merged GraphQL schema +- Sets the path as `/graphql` and exposes a GraphQL Playground for GET requests on `/graphql` + +## The Reaction GraphQL Context + +All GraphQL resolvers receive a [context](https://www.apollographql.com/docs/apollo-server/data/resolvers/#the-context-argument) object as their third argument. The base context is built within the `ReactionAPI` constructor, and additional request-specific properties (like `accountId` and `userHasPermission`) are added to it in `buildContext.js`. + +In Jest tests, you can get a mock context object with mock functions on it: + +```js +import mockContext from "/imports/test-utils/helpers/mockContext"; +``` + +Here’s what's on the context object: +- Queries registered by plugins: `context.queries` +- Mutations registered by plugins: `context.mutations` +- The current user: `context.user` +- The current user’s ID: `context.userId` +- The current account: `context.account` +- The current account ID: `context.accountId` +- The default shop ID (this may go away): `context.shopId` +- To check permissions: `context.userHasPermission(role, shopId)` (returns true or false) +- To check permissions and throw error: `context.checkPermissions(role, shopId)` +- MongoDB collections: `context.collections` +- The `ReactionAPI` instance: `context.app` +- App events object: + - To emit: `context.appEvents.emit` + - To listen: `context.appEvents.on` +- To retrieve all functions registered as a specific type of function: `context.getFunctionsOfType(type)` +- The app root URL: `context.rootUrl` +- To convert a relative URL to absolute (prefix with the root URL): `context.getAbsoluteUrl(path)` + +## How Auth Works + +Refer to [Developer Concepts: Authentication](./developer-authentication) + +## IDs in GraphQL + +All IDs are exposed in GraphQL as globally unique IDs on fields named `_id`. When we finalize the GraphQL API, we may change this field name to `id`, which is more commonly used in the GraphQL world. + +The GraphQL server specification has no opinion on what a type's ID field should look like, but it does provide [a built-in ID type](https://graphql.github.io/graphql-spec/draft/#sec-ID). + +> The ID scalar type represents a unique identifier, often used to refetch an object or as the key for a cache. The ID type is serialized in the same way as a String; however, it is not intended to be human‐readable. While it is often numeric, it should always serialize as a String. + +In particular, note that "it is not intended to be human‐readable". You should never display a field of type `ID` anywhere. They are only for references. If your data comes over from another system and has IDs with some meaning, then you should also store them on a different field where the raw value will not be obfuscated by the GraphQL layer. + +Note also that the server specification does not necessarily care whether an ID is globally unique. However, we intend compatibility with both Relay and Apollo for client-side frameworks, and [the Relay specification](https://facebook.github.io/relay/graphql/objectidentification.htm#sec-Node-Interface) does have a requirement here: + +> This `id` should be a globally unique identifier for this object, and given just this `id`, the server should be able to refetch the object. + +In most cases, actual internal data IDs are in MongoDB collections, so they are guaranteed unique within the collection, but not among all collections. To add that extra layer of uniqueness, we concatenate the namespace with the internal ID, and then to keep it looking like a "not human‐readable" ID, we base64 encode. + +To convert internal IDs to opaque UUIDs, we first prefix them with "reaction/\" and then base64 encode them. The primary transformation functions that handle this are in the `api-utils` package. + +The GraphQL resolver functions are the place where ID encoding and decoding happens. They then call out to plugin functions that deal exclusively with internal IDs. Any IDs returned by such functions must also be transformed before returning them, although this typically and preferably happens in a type resolver. + +### Checking whether an operation was successful + +For `insertOne` or `insertMany`: + +```js +const { insertedCount } = await SomeCollection.insertOne(/* ... */); +if (insertedCount === 0) { + // throw Error or otherwise handle failure +} +``` + +For `updateOne` or `updateMany`: + +```js +const { modifiedCount } = await SomeCollection.updateOne(/* ... */); +if (modifiedCount === 0) { + // throw Error or otherwise handle failure +} +``` + +For `deleteOne`: + +```js +const { deletedCount } = await SomeCollection.updateOne(/* ... */); +if (deletedCount === 0) { + // throw Error or otherwise handle failure +} +``` + +Keep in mind that sometimes a zero `modifiedCount` or `deletedCount` might be because nothing matched your query, and depending on the situation, this may not be an error. If you foresee this situation, you can opt to check `matchedCount` instead. + +## Optimizing GraphQL resolvers + +Because of the way GraphQL queries and relationships work, sometimes a query will include something like this: + +```graphql +{ + order { + shop { + _id + } + } +} +``` + +Normally the `shop` relationship would result in a database query, but if `order` already has a `shopId` property, we can actually skip the database lookup because the client has requested only the `_id` property. There is a utility function that helps with this: `optimizeIdOnly`. Check out the `Query.viewer` resolver for an example of how to use it. + +## Documenting GraphQL Functions + +Reaction GraphQL resolver functions, like all JavaScript functions in all Reaction code, must have JSDoc comments above them. See the [JSDoc Style Guide](../../../docs/jsdoc-style-guide.md) + +## Writing Tests + +Reaction GraphQL is tested through a combination of unit tests and integration tests, all written in and executed with Jest. Specifically, the coverage requirements are: + +- Each query or mutation function in plugins must have unit tests in a `.test.js` file alongside the file being tested. +- Each resolver that is doing anything more than just referencing another function must have a unit test in a `.test.js` file alongside the file being tested. +- The primary expected uses of all queries and mutations must be tested in integration tests in the `/tests` root folder. This helps ensure that all of the related resolvers are working together properly and using correct database calls. + +Refer to [Testing Requirements](./testing-requirements.md) diff --git a/guides/developers-guide/core/writing-integration-tests.md b/guides/developers-guide/core/graphql-resolvers-file-structure.md similarity index 100% rename from guides/developers-guide/core/writing-integration-tests.md rename to guides/developers-guide/core/graphql-resolvers-file-structure.md diff --git a/guides/developers-guide/core/writing-unit-tests.md b/guides/developers-guide/core/writing-unit-tests.md deleted file mode 100644 index e69de29..0000000 diff --git a/how-tos/add-collections-from-plugin.md b/how-tos/add-collections-from-plugin.md new file mode 100644 index 0000000..3a9cd5c --- /dev/null +++ b/how-tos/add-collections-from-plugin.md @@ -0,0 +1,44 @@ +# How do I add a MongoDB collection from a plugin + +To create any non-core MongoDB collection that a plugin needs, use the `collections` option in your plugin's `registerPlugin` call: + +```js +export default async function register(app) { + await app.registerPlugin({ + label: "My Custom Plugin", + name: "my-custom-plugin", + collections: { + MyCustomCollection: { + name: "MyCustomCollection" + } + } + // other props + }); +} +``` + +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 that usually MongoDB will not actually create the collection until the first time you insert into it. + +## Ensure MongoDB collection indexes from a plugin + +You can add indexes for your MongoDB collection in the same place you define your collection, the `collections` object of your `registerPlugin` call: + +```js +export default async function register(app) { + await app.registerPlugin({ + label: "My Custom Plugin", + name: "my-custom-plugin", + collections: { + MyCustomCollection: { + name: "MyCustomCollection", + indexes: [ + [{ referenceId: 1 }, { unique: true }] + ] + } + } + // other props + }); +} +``` diff --git a/how-tos/add-extra-data-to-a-product.md b/how-tos/add-extra-data-to-a-product.md index e69de29..985a981 100644 --- a/how-tos/add-extra-data-to-a-product.md +++ b/how-tos/add-extra-data-to-a-product.md @@ -0,0 +1,106 @@ +# How To: Add Data to a Product + +## Prerequisite Reading +- [Catalogs](../guides/developers-guide/concepts/catalog-and-publishing.md) +- [Understanding Plugins](../guides/developers-guide/core/build-api-plugin.md) +- [Extending Schemas](./extend-an-existing-schema.md) + +## Overview + +As a developer customizing Reaction, you may find a need to add some custom property to products. You should avoid this if you can achieve your goals some other way, such as using `metafields`, tags, or a separate data store that references product IDs. But in some cases, extending products is the best way. + +Because products have a publishing flow and have variants, extending them requires many steps. In general, they are as follows: +- Extend database schemas +- Extend GraphQL schemas +- Register your custom property as published, if it should be published to the catalog +- Register a function that publishes your custom property, if it should be published to the catalog +- Create a GraphQL mutation for setting your custom property value +- Create a React component that allows an operator to set the custom property value, and wire it up to your mutation, or set your property in some other way + +### Extend database schemas + +To extend any database schema, you just need a file that is imported into server code. We recommend using a file named `simpleSchemas.js` in your plugin, and then importing that file in your plugin's `index.js`. + +Refer to [SimpleSchema docs](https://github.com/aldeed/simple-schema-js#schema-rules) for more information about the object you pass to `extend`. + +```js +const { simpleSchemas: { CatalogProduct, CatalogVariantSchema, Product, ProductVariant, VariantBaseSchema }} = context; + +const schemaExtension = { + myProperty: { + type: String, + optional: true + } +}; + +// Extend the Product database schema, if your custom property will be on products +Product.extend(schemaExtension); + +// Extend the Variant database schema, if your custom property will be on variants +ProductVariant.extend(schemaExtension); + +// Extend the CatalogProduct database schema, if your custom property will be on products +CatalogProduct.extend(schemaExtension); + +// Extend the catalog variant database schemas, if your custom property will be on variants. There are two schemas for this one. +VariantBaseSchema.extend(schemaExtension); +CatalogVariantSchema.extend(schemaExtension); +``` + +### Extend GraphQL schemas + +- Extend the `Product` GraphQL type, if your custom property will be on products +- Extend the `ProductVariant` GraphQL type, if your custom property will be on variants +- Extend the `CatalogProduct` GraphQL type, if your custom property will be on products and is published to the catalog +- Extend the `CatalogProductVariant` GraphQL type, if your custom property will be on variants and is published to the catalog +- Create a GraphQL resolver for your property if it needs any transformation + +Refer to [How To: Extend GraphQL to add a field](./extend-graphql-to-add-field.md). + +### Register custom property as published + +*Skip this step if your property is not needed in the published catalog* + +A plugin can include a `catalog` object in `registerPlugin`, with `customPublishedProductFields` and `customPublishedProductVariantFields` that are set to arrays of property names. These will be appended to the core list of fields for which published status should be tracked. This is used to build the hashes that are used to display an indicator when changes need to be published. + +```js +export default async function register(app) { + await app.registerPlugin({ + catalog: { + customPublishedProductFields: ["myProperty"], + customPublishedProductVariantFields: ["myProperty"] + }, + // other props + }); +} +``` + +### Register a function that publishes custom property + +*Skip this step if your property is not needed in the published catalog* + +```js +import publishProductToCatalog from "./publishProductToCatalog"; + +export default async function register(app) { + await app.registerPlugin({ + functionsByType: { + publishProductToCatalog: [publishProductToCatalog] + }, + // other props + }); +} +``` + +Where the `publishProductToCatalog` function adds the required fields to `catalogProduct`. For example: + +```js +export default function publishProductToCatalog(catalogProduct, { context, product, shop, variants }) { + catalogProduct.myProperty = product.myProperty; + // Also set on each catalogProduct.variants if necessary +} +``` + +### Create a GraphQL mutation for setting your custom property value + +Refer to [How To: Create a new GraphQL mutation](./create-graphql-mutation.md). After mutating the `Product` document in your function, you must also call `context.mutations.hashProduct(, context.collections, false)`. This will update the current product hash, causing the operator UI to indicate that there are changes needing publishing. diff --git a/how-tos/create-an-inventory-plugin.md b/how-tos/create-an-inventory-plugin.md new file mode 100644 index 0000000..423b762 --- /dev/null +++ b/how-tos/create-an-inventory-plugin.md @@ -0,0 +1,80 @@ +# How To: Create an Inventory Data Provider Plugin + +Before you create a custom inventory data provider plugin, make sure that you need one. +- If your inventory system outputs periodic files but doesn't allow you to read inventory programmatically in real time, you may want to use the "Simple Inventory" plugin and only update the values. Refer to option 2 [here](./core-plugins-simple-inventory#how-to-sync-inventory-quantities-from-an-external-system). +- If you use a well known third-party inventory system, there may be a community plugin already available for it. If not, consider making yours available as open source so that the community can help you maintain it. + +Once you're sure you need to create a custom inventory plugin, use the standard steps to create the plugin boilerplate. Beyond that, an inventory plugin needs to do the following: +- Register an `inventoryForProductConfigurations` type function that returns current inventory in stock and other related data for a list of product configurations. +- Optionally provide a way of editing the inventory in stock in the operator UI, by registering client components +- Optionally track order status changes to keep track of "reserved" inventory, which is inventory that is still technically in stock but should not be considered available to sell. + +## Register an `inventoryForProductConfigurations` function + +An `inventoryForProductConfigurations` function must look up inventory information for the received `productConfiguration` from your external inventory system. Each item in the return array must have the same `productConfiguration` object and an `inventoryInfo` object field with all of the fields shown in the example code below. + +If your plugin is unsure what the inventory is for this product configuration, it must return `null` for the `inventoryInfo` field. It's possible to have multiple plugins providing inventory info, and some other plugin might know what the inventory is. Returning `null` tells the core "inventory" plugin to ask the next inventory data provider or use default values if all inventory data plugins return `null`. + +```js +/** + * @summary Returns an object with inventory information for one or more + * product configurations. + * @param {Object} context App context + * @param {Object} input Additional input arguments + * @param {Object[]} input.productConfigurations An array of ProductConfiguration objects + * @return {Promise} Array of responses + */ +export default async function inventoryForProductConfigurations(context, input) { + const { collections } = context; + const { SimpleInventory } = collections; + const { productConfigurations } = input; + + return productConfigurations.map((productConfiguration) => { + // Look up inventory information for `productConfiguration` from external system. + + return { + inventoryInfo: { + canBackorder, + inventoryAvailableToSell, + inventoryInStock, + inventoryReserved, + isLowQuantity + }, + productConfiguration + }; + }); +} +``` + +Pass your function in the `functionsByType` list: + +```js +import inventoryForProductConfigurations from "./inventoryForProductConfigurations"; + +/** + * @summary Import and call this function to add this plugin to your API. + * @param {ReactionAPI} app The ReactionAPI instance + * @return {undefined} + */ +export default async function register(app) { + await app.registerPlugin({ + label: "My Inventory Data Plugin", + name: "my-inventory-data-plugin", + functionsByType: { + inventoryForProductConfigurations: [inventoryForProductConfigurations] + } + }); +} +``` + +> You may be aware that the core inventory plugin provides two queries: `inventoryForProductConfigurations` (multiple) and `inventoryForProductConfiguration` (single). The single version of the function is a convenience wrapper that calls the multiple version. Thus, an inventory data plugin needs to provide only this one function, which expects multiple product configurations. + +## Optionally add UI components + +How and whether you should do this step depends a lot on how your plugin is tracking inventory. If you want any information to be viewable or editable in the operator UI, you can extend the GraphQL API and register UI blocks to appear where you need them. + +## Track reserved/available inventory + +In addition to providing "in stock" inventory values, most inventory data providers also somehow track "reserved" inventory and/or automatically increase or decrease the "in stock" and "available" values as orders are placed, approved, and fulfilled. Refer to the `startup` function in the Simple Inventory plugin for one example of how you can do this. The rest of the Reaction system does not care how you do this, and for a low volume shop you may be able to skip it entirely. + +> If you do track reserved inventory, it can be an inexact science. We recommend that you provide operators with a way of manually fixing or updating the reserved quantity, as the Simple Inventory plugin does with the `recalculateReservedSimpleInventory` GraphQL mutation and corresponding UI button. diff --git a/how-tos/create-and-run-migrations.md b/how-tos/create-and-run-migrations.md new file mode 100644 index 0000000..d16d21a --- /dev/null +++ b/how-tos/create-and-run-migrations.md @@ -0,0 +1,51 @@ +# How to create and run migrations + +## Prerequisites +Must be running node 14.17 or later + +## Adding a migration to an API plugin package +You can look at the [simple-authorization](https://github.com/reactioncommerce/plugin-simple-authorization) plugin code for an example to follow. +To add a migration to an API plugin package, follow the below steps: + +1. In the plugin package create a `migrations` folder alongside the `src` folder. +2. Create `migrationsNamespace.js` and add the line `export const migrationsNamespace = "";`. +3. Create `.js` which will house the `up()` and `down()`. The `up` and `down` functions should do whatever they need to do to move data from your N-1 or N+1 schema to your N schema. Both types of functions receive a migration context, which has a connection to the MongoDB database and a progress function for reporting progress. The keys of the migration object are the database version numbers. These must be a single number (2) or two numbers separated by a dash (2-1) if you need to branch off your main migration path to support previous major releases. Only one branch level is allowed. +4. Create `index.js` which exports the namespace and migration script. +5. Add `export { default as migrations } from "./migrations/index.js";` in your plugin entry point file i.e, `/index.js`. +6. Add the latest version of the `@reactioncommerce/db-version-check` NPM package as a dependency using `npm install @reactioncommerce/db-version-check@latest`. +7. Add and register a `preStartup` function in `src/index.js`. In it, call `doesDatabaseVersionMatch` to prevent API startup if the data isn't compatible with the code. The preStartup.js is responsible for identifying version mismatch. It throws an error if a migration is required and prevents api from running. + +### In api-migrations +1. Add the new track to `api-migrations/migrator.config.js` following the existing syntax. The app looks at this file to pick the desired version and checks against the migrations table in the DB to identify the current version. +2. Install the plugin package using `npm install @reactioncommerce/` + +These steps are explained in more detail [here](https://github.com/reactioncommerce/migrator#how-to-publish-a-package-with-migrations), and you can look at the [simple-authorization](https://github.com/reactioncommerce/plugin-simple-authorization) plugin code for an example to follow. + +IMPORTANT: If the plugin you added a migration to is one that is built in to the stock Reaction API releases, then at the same time you bump the plugin package version in https://github.com/reactioncommerce/reaction `trunk` branch, you must also update the data version in the trunk branch of `migrator.config.js` in this repo. + +## Running migrations locally for development + +1. Fork/clone this repo. +2. Check out the [tag](https://github.com/reactioncommerce/api-migrations/tags) that corresponds to your version of Reaction Platform. (Only 3.0.0 and higher are supported.) +3. `npm install` +4. Then to see a report of necessary migrations for your local MongoDB database and optionally run them: + +```sh +MONGO_URL=mongodb://localhost:27017/reaction npx migrator migrate +``` + +Use a different `MONGO_URL` to run them on a different database. + +Refer to [https://github.com/reactioncommerce/migrator](https://github.com/reactioncommerce/migrator) docs for other commands. Prefix them with `npx`. +5. Try to start your API service. If there are database version errors thrown on startup, then change the versions or add/remove tracks in `migrator.config.js` as necessary based on whatever those errors are asking for. Then repeat the previous step. (If you've added new tracks, you'll need to `npm install` the latest version of those packages first.) Keep doing this until the API service starts. + +## Migrating Deployment Databases + +Option 1: You can follow the above "Local Development Usage" instructions but specify a remote database if you want. Migrations will run on your computer, which may not be very fast or reliable. + +Option 2: You can set up a CI task for this repo: + +1. Create different configuration files for each deployed environment. For example, `migrator.config-staging.js` for the "staging" environment. +2. Add the necessary `MONGO_URL`s to your CI environment/secrets. +3. When config file changes are merged to the main branch, run `npx migrator migrate -y` as a CI task with `MONGO_URL` set to the correct database for that environment. Do this for each Reaction environment (database) you have. + - Ensure that your CI Docker image uses at least the version of Node that's in the `.nvmrc` file. diff --git a/how-tos/create-graphql-mutation.md b/how-tos/create-graphql-mutation.md new file mode 100644 index 0000000..4a6fca6 --- /dev/null +++ b/how-tos/create-graphql-mutation.md @@ -0,0 +1,230 @@ +# How To: Create a new GraphQL mutation + +## Step 1: Identify which plugin owns the mutation + +The complete Reaction Commerce GraphQL API is created by stitching together domain-specific APIs from all of the API plugins. So when adding a new mutation, the first step is to decide which plugin should own it. This is usually obvious, but not always. You should think about whether any other plugins or services will need to call your mutation. If the mutation is fundamental to the system, then it may need to go in the "core" plugin, if no better alternative exists. + +## Step 2: Understand the difference between a plugin mutation function and a GraphQL mutation resolver + +See [Resolver Mutations and Queries vs. Plugin Mutations and Queries](../guides/developers-guide/core/developing-graphql.md#resolver-mutations-and-queries-vs-plugin-mutations-and-queries) + +## Step 3: Define the mutation in the schema + +1. If it doesn't already exist, create `schemas` folder in the plugin, and add an `index.js` file there. +1. If it doesn't already exist, create `schema.graphql` in `schemas` in the plugin. +1. Import the GraphQL file into `index.js` and default export it in an array: + + ```js + import importAsString from "@reactioncommerce/api-utils/importAsString.js"; + + const schema = importAsString("./schema.graphql"); + + export default [schema]; + ``` + + > NOTE: For large plugins, you can split to multiple `.graphql` files and export a multi-item array. + +1. In the `.graphql` file, add your mutation within `extend type Mutation { }`. Add an `extend type Mutation` section near the top if the file doesn't have it yet. +1. Follow [the Relay recommendations](https://facebook.github.io/relay/graphql/mutations.htm) for mutation input arguments, which is to have only one argument named `input` that takes an input type that is the capitalized mutation name plus the suffix "Input", and to return a type that is the capitalized mutation name plus the suffix "Payload". + + Example: `addAccountEmailRecord(input: AddAccountEmailRecordInput!): AddAccountEmailRecordPayload` + +1. Add the Input and Payload types to the schema. Both must have `clientMutationId: String` field and may have any other fields as necessary. The mutation response payload should include whatever object was mutated. +1. Document your mutation, the new types, and all fields in those types using string literals. See [Documenting a GraphQL Schema](../guides/developers-guide/core/developing-graphql.md#documenting-a-graphql-schema). +1. If not already done, register your schemas in the plugin's `index.js` file: + + ```js + import schemas from "./schemas"; + + export default async function register(app) { + await app.registerPlugin({ + graphQL: { + schemas + }, + // other props + }); + } + ``` + +## Step 4: Create the plugin mutation file + +1. If it doesn't already exist, create `mutations` folder in the plugin, and add an `index.js` file there. +2. In `mutations`, create a file for the mutation, e.g. `createSomething.js` for the `createSomething` mutation. The file should look something like this: + +```js +import Logger from "@reactioncommerce/logger"; + +/** + * @method createSomething + * @summary TODO + * @param {Object} context - an object containing the per-request state + * @return {Promise} TODO + */ +export default async function createSomething(context) { + Logger.info("createSomething mutation is not yet implemented"); + return null; +} +``` + +## Step 5: Add the plugin mutation to the mutations context + +In `mutations/index.js` in the plugin, import your mutation and add it to the default export object. Example: + +```js +import createSomething from "./createSomething" + +export default { + createSomething +}; +``` + +If this is the first mutation for the plugin, you'll also need to pass the full `mutations` object to `registerPlugin` in the plugin's `index.js` file: + +```js +import mutations from "./mutations"; + +export default async function register(app) { + await app.registerPlugin({ + mutations, + // other props + }); +} +``` + +Your plugin mutation function is now available in the GraphQL context as `context.mutations.createSomething`. + +> NOTE: The mutations objects from all plugins are merged, so be sure that another plugin does not have a mutation with the same name. The last one registered with that name will win, and plugins are generally registered in alphabetical order by plugin name. Tip: You can use this to your advantage if you want to override the mutation function of a core plugin without modifying core code. + +## Step 6: Add a test file for your mutation + +If your mutation is in a file named `createSomething.js`, your Jest tests should be in a file named `createSomething.test.js` in the same folder. Initially you can copy and paste the following test: + +```js +import mockContext from "/imports/test-utils/helpers/mockContext"; +import createSomething from "./createSomething"; + +test("expect to return a Promise that resolves to null", async () => { + const result = await createSomething(mockContext); + expect(result).toEqual(null); +}); +``` + +## Step 7: Create the GraphQL mutation resolver file + +1. If it doesn't already exist, create `resolvers` folder in the plugin, and add an `index.js` file there. +2. If it doesn't already exist, create `resolvers/Mutation` folder in the plugin, and add an `index.js` file there. "Mutation" must be capitalized. +3. In `resolvers/Mutation`, create a file for the mutation resolver, e.g. `createSomething.js` for the `createSomething` mutation. The file should look something like this initially: + +```js +/** + * @name "Mutation.createSomething" + * @method + * @memberof MyPlugin/GraphQL + * @summary resolver for the createSomething GraphQL mutation + * @param {Object} parentResult - unused + * @param {Object} args.input - an object of all mutation arguments that were sent by the client + * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call + * @param {Object} context - an object containing the per-request state + * @return {Promise} CreateSomethingPayload + */ +export default async function createSomething(parentResult, { input }, context) { + const { clientMutationId = null } = input; + // TODO: decode incoming IDs here + const renameMe = await context.mutations.createSomething(context); + return { + renameMe, + clientMutationId + }; +} +``` + +Make adjustments to the resolver function so that it reads and passes along the parameters correctly. The general pattern is: +- Destructure `input`. Include `clientMutationId`. +- Decode any opaque IDs that are in the input object +- Call `context.mutations.` (your new plugin mutation) with the necessary arguments, and `await` a response. +- Return an object that contains the `clientMutationId` and the object returned by the plugin mutation. (This must match the "Payload" type from the schema.) + +## Step 8: Register the resolver + +In `resolvers/Mutation/index.js` in the plugin, import your mutation resolver and add it to the default export object. Example: + +```js +import createSomething from "./createSomething" + +export default { + createSomething +}; +``` + +If this is the first mutation for the plugin, you'll also need to import the `Mutation` object into the `resolvers` object. In `resolvers/index.js` in the plugin, import `Mutation` and add it to the default export object. + +```js +import Mutation from "./Mutation" + +export default { + Mutation +}; +``` + +Then pass the full `resolvers` object to `registerPlugin` in the plugin's `index.js` file: + +```js +import resolvers from "./resolvers"; +import schemas from "./schemas"; + +export default async function register(app) { + await app.registerPlugin({ + graphQL: { + resolvers, + schemas + }, + // other props + }); +} +``` + +Calling your mutation with GraphQL should now work. + +## Step 9: Add a test file for your mutation resolver + +If your mutation resolver is in a file named `createSomething.js`, your Jest tests should be in a file named `createSomething.test.js` in the same folder. Initially you can copy and paste the following test: + +```js +import createSomething from "./createSomething"; + +test("correctly passes through to mutations.createSomething", async () => { + const fakeResult = { /* TODO */ }; + + const mockMutation = jest.fn().mockName("mutations.createSomething"); + mockMutation.mockReturnValueOnce(Promise.resolve(fakeResult)); + const context = { + mutations: { + createSomething: mockMutation + } + }; + + const result = await createSomething(null, { + input: { + /* TODO */ + clientMutationId: "clientMutationId" + } + }, context); + + expect(result).toEqual({ + renameMe: fakeResult, + clientMutationId: "clientMutationId" + }); +}); +``` + +This of course should be updated with tests that are appropriate for whatever your mutation resolver does. For example, verify that all ID and schema transformations happen. + +## Step 10: Finish implementing your mutation + +Adjust the mutation function and the mutation resolver function until they work as expected, with tests that prove it. This will likely involve adding additional input fields, ID transformations, permission checks, MongoDB calls, and event emitting. + +Refer to [Developing the GraphQL API](../guides/developers-guide/core/developing-graphql.md) for answers to any questions you might have while implementing your mutation. + +## Step 11: Update the JSDoc comments + +Write/update jsdoc comments for the plugin mutation function, the mutation resolver, and any util functions. The resolver function must have `@memberof /GraphQL` in the jsdoc, and the `@name` must be the full GraphQL schema path in quotation marks, e.g., "Mutation.createSomething". (The quotation marks are necessary for the output API documentation to be correct due to the periods.) diff --git a/how-tos/create-graphql-query.md b/how-tos/create-graphql-query.md new file mode 100644 index 0000000..bbc61c8 --- /dev/null +++ b/how-tos/create-graphql-query.md @@ -0,0 +1,232 @@ +# How To: Create a new GraphQL query + +## Step 1: Identify which plugin owns the query + +The complete Reaction Commerce GraphQL API is created by stitching together domain-specific APIs from all of the API plugins. So when adding a new query, the first step is to decide which plugin should own it. This is usually obvious, but not always. You should think about whether any other plugins or services will need to call your query. If the query is fundamental to the system, then it may need to go in the "core" plugin, if no better alternative exists. + +## Step 2: Understand the difference between a plugin query function and a GraphQL query resolver + +See [Resolver Mutations and Queries vs. Plugin Mutations and Queries](../guides/developers-guide/core/developing-graphql.md#resolver-mutations-and-queries-vs-plugin-mutations-and-queries) + +## Step 3: Name the query + +When choosing a name for the query, there are a few rules to follow: +- In keeping with general GraphQL best practices, do not use verbs such as "list", "get", or "find" at the beginning of your query name. For example, use "cart" instead of "getCart" and "carts" instead of "listCarts". +- Prefix with adjectives as necessary to fully describe what the query returns. For example, "anonymousCart" and "accountCart" queries. +- If there are similar queries that take slightly different parameters, add a suffix to clarify. In most cases, we begin the suffix with "By". For example, "accountCartById" and "accountCartByAccountId". + +## Step 4: Define the query in the schema + +1. If it doesn't already exist, create `schemas` folder in the plugin, and add an `index.js` file there. +1. If it doesn't already exist, create `schema.graphql` in `schemas` in the plugin. +1. Import the GraphQL file into `index.js` and default export it in an array: + + ```js + import importAsString from "@reactioncommerce/api-utils/importAsString.js"; + + const schema = importAsString("./schema.graphql"); + + export default [schema]; + ``` + + > NOTE: For large plugins, you can split to multiple `.graphql` files and export a multi-item array. + +1. In the `.graphql` file, add your query within `extend type Query { }`. Add an `extend type Query` section near the top if the file doesn't have it yet. +1. If your query returns multiple documents, it should return a Relay-compatible Connection and accept standard connection arguments. This is true of any `fields` on any types you create as well. + + Example: `groups(after: ConnectionCursor, before: ConnectionCursor, first: ConnectionLimitInt, last: ConnectionLimitInt, sortOrder: SortOrder = asc, sortBy: GroupSortByField = createdAt): GroupConnection` + +1. Document your query, the new types, and all fields in those types using string literals. See [Documenting a GraphQL Schema](../guides/developers-guide/core/developing-graphql.md#documenting-a-graphql-schema). +1. If not already done, register your schemas in the plugin's `index.js` file: + + ```js + import schemas from "./schemas"; + + export default async function register(app) { + await app.registerPlugin({ + graphQL: { + schemas + }, + // other props + }); + } + ``` + +## Step 5: Create the plugin query file + +1. If it doesn't already exist, create `queries` folder in the plugin, and add an `index.js` file there. +2. In `queries`, create a file for the query, e.g. `widgets.js` for the `widgets` query. The file should look something like this: + +```js +import Logger from "@reactioncommerce/logger"; + +/** + * @method widgets + * @summary TODO + * @param {Object} context - an object containing the per-request state + * @return {Promise} TODO + */ +export default async function widgets(context) { + Logger.info("widgets query is not yet implemented"); + return null; +} +``` + +## Step 6: Add the plugin query to the queries context + +In `queries/index.js` in the plugin, import your query and add it to the default export object. Example: + +```js +import widgets from "./widgets" + +export default { + widgets +}; +``` + +If this is the first query for the plugin, you'll also need to pass the full `queries` object to `registerPlugin` in the plugin's `index.js` file: + +```js +import queries from "./queries"; + +export default async function register(app) { + await app.registerPlugin({ + queries, + // other props + }); +} +``` + +Your plugin query function is now available in the GraphQL context as `context.queries.widgets`. + +> NOTE: The queries objects from all plugins are merged, so be sure that another plugin does not have a query with the same name. The last one registered with that name will win, and plugins are generally registered in alphabetical order by plugin name. Tip: You can use this to your advantage if you want to override the query function of a core plugin without modifying core code. + +## Step 7: Add a test file for your query + +If your query is in a file named `widgets.js`, your Jest tests should be in a file named `widgets.test.js` in the same folder. Initially you can copy and paste the following test: + +```js +import mockContext from "/imports/test-utils/helpers/mockContext"; +import widgets from "./widgets"; + +test("expect to return a Promise that resolves to null", async () => { + const result = await widgets(mockContext); + expect(result).toEqual(null); +}); +``` + +This of course should be updated with tests that are appropriate for whatever your query does. + +## Step 8: Create the GraphQL query resolver file + +1. If it doesn't already exist, create `resolvers` folder in the plugin, and add an `index.js` file there. +2. If it doesn't already exist, create `resolvers/Query` folder in the plugin, and add an `index.js` file there. "Query" must be capitalized. +3. In `resolvers/Query`, create a file for the query resolver, e.g. `widgets.js` for the `widgets` query. The file should look something like this initially: + +```js +/** + * @name "Query.widgets" + * @method + * @memberof MyPlugin/GraphQL + * @summary resolver for the widgets GraphQL query + * @param {Object} parentResult - unused + * @param {Object} args - an object of all arguments that were sent by the client + * @param {Object} context - an object containing the per-request state + * @return {Promise} TODO + */ +export default async function widgets(parentResult, args, context) { + // TODO: decode incoming IDs here + return context.queries.widgets(context); +} +``` + +Make adjustments to the resolver function so that it reads and passes along the parameters correctly. The general pattern is: +- Decode any opaque IDs that are in the arguments +- Call `context.queries.` (your new plugin query) with the necessary arguments, and `await` a response. +- Return a single document or an array of them using either `getPaginatedResponse` or `xformArrayToConnection` util function. + +## Step 9: Register the resolver + +In `resolvers/Query/index.js` in the plugin, import your query resolver and add it to the default export object. Example: + +```js +import widgets from "./widgets" + +export default { + widgets +}; +``` + +If this is the first query for the plugin, you'll also need to import the `Query` object into the `resolvers` object. In `resolvers/index.js` in the plugin, import `Query` and add it to the default export object. + +```js +import Query from "./Query" + +export default { + Query +}; +``` + +If you are returning multiple documents (see step #3) you'll need to add an additional export here, `getConnectionTypeResolvers`, in order to be able to query `edges->node`: + +```js +import { getConnectionTypeResolvers } from "@reactioncommerce/reaction-graphql-utils"; +import Query from "./Query" + +export default { + Query + ...getConnectionTypeResolvers("QueryName") +}; +``` + +Then pass the full `resolvers` object to `registerPlugin` in the plugin's `index.js` file: + +```js +import resolvers from "./resolvers"; +import schemas from "./schemas"; + +export default async function register(app) { + await app.registerPlugin({ + graphQL: { + resolvers, + schemas + }, + // other props + }); +} +``` + +Calling your query with GraphQL should now work. + +## Step 10: Add a test file for your query resolver + +If your query resolver is in a file named `widgets.js`, your Jest tests should be in a file named `widgets.test.js` in the same folder. Initially you can copy and paste the following test: + +```js +import widgets from "./widgets"; + +test("calls queries.widgets and returns the result", async () => { + const mockResponse = "MOCK_RESPONSE"; + const mockQuery = jest.fn().mockName("queries.widgets").mockReturnValueOnce(Promise.resolve(mockResponse)); + + const result = await widgets(null, { /* TODO */ }, { + queries: { widgets: mockQuery }, + userId: "123" + }); + + expect(result).toEqual(mockResponse); + expect(mockQuery).toHaveBeenCalled(); +}); +``` + +This of course should be updated with tests that are appropriate for whatever your query resolver does. For example, verify that all ID and schema transformations happen. + +## Step 11: Finish implementing your query + +Adjust the query function and the query resolver function until they work as expected, with tests that prove it. This will likely involve adding additional arguments, ID transformations, permission checks, and MongoDB queries. + +Refer to [Developing the GraphQL API](./graphql-developing) for answers to any questions you might have while implementing your mutation. + +## Step 12: Update the JSDoc comments + +Write/update jsdoc comments for the plugin query function, the query resolver, and any util functions. The resolver function must have `@memberof /GraphQL` in the jsdoc, and the `@name` must be the full GraphQL schema path in quotation marks, e.g., "Query.widgets". (The quotation marks are necessary for the output API documentation to be correct due to the periods.) diff --git a/how-tos/emit-and-listen-for-events.md b/how-tos/emit-and-listen-for-events.md deleted file mode 100644 index e69de29..0000000 diff --git a/how-tos/emit-and-listen-to-events.md b/how-tos/emit-and-listen-to-events.md new file mode 100644 index 0000000..fd07202 --- /dev/null +++ b/how-tos/emit-and-listen-to-events.md @@ -0,0 +1,67 @@ +# How do I: Emit and Listen for AppEvents + +## Background: + +AppEvents is a simple interface that allows you to build a more event-drive approach. This allows one part of the app to announce that something important has happened, and other parts of the app +to "react" without the emitter needing to know anything about the listeners. We call sending events "emitting" and listeners "handlers". A simple example of where OC uses appEvents is that when +an order is created an event is emitted for "orderPlaced". Currently there is a listener that listens for this event and sends an email. But what if you wanted to also send a text and a Slack message? +Using a listener in your plugin you could listen for this same event and do whatever action you like without need to modify the original code, providing a convenient way of keeping these uncoupled. + +## Get the appEvent object from the context + +In any function that receives context (which should be most of them) you should be able to extract the appEvents by doing: + +```js +const { appEvents } = context; +``` + +## Emit an app event + +Emit app events in API code using `appEvents.emit`. The `emit` function takes at least two parameters: the name of the event as a string and the `payload` of functions as an object. + +### Function parameters and options + +- *Event name*: The first argument is the event name, as a string. There is currently no limit to what event name you can emit, but generally try to follow established patterns for naming. +- *Payload*: The second argument, the `payload`, should always be an object, and not an entity object. Rather than passing `order` in directly, pass it in an object: `{ order }`, so that more fields can be added or removed from the payload more easily. +- *Option arguments*: The last argument is an array of arguments to pass to each function from `payload`. + +> **Note**: Using `await`*: Using the method with `await` will not resolve until all registered handler methods have resolved. + +## Emit an app event + +```js +context.appEvents.emit("eventName", payload, options); +``` + +## Listen for an app event + +See ["Run plugin code on app startup"](run-function-on-startup.md) and attach event listeners in startup code. + +```js title=startup.js +export default function startup(context) { + const { appEvents } = context; + + appEvents.on("eventName", (payload, options) => { // if you pass in context here, all your listeners will have the context available to them + // Handle the event + }); +} +``` + +## Avoid infinite loops + +It's possible to get stuck in an infinite loop of emitting and listening for the same event. To avoid this, pass `emittedBy` key with a string value in the third options parameter on the `emit`, and check for it on the `on` function: + +```js +const EMITTED_BY_NAME = "myPluginHandler"; + +appEvents.on("afterCartUpdate", async ({ cart }, { emittedBy } = {}) => { + if (emittedBy === EMITTED_BY_NAME) return; // short circuit infinite loops + + appEvents.emit("afterCartUpdate", { cart: updatedCart, updatedBy: userId }, { emittedBy: EMITTED_BY_NAME }); +}); +``` + +## Events + +Developers are encouraged to add AppEvent emitters to their code whenever they create a serious event, allowing custom plugin authors to use this for extension + diff --git a/how-tos/extend-an-existing-schema.md b/how-tos/extend-an-existing-schema.md index e69de29..a28fc4f 100644 --- a/how-tos/extend-an-existing-schema.md +++ b/how-tos/extend-an-existing-schema.md @@ -0,0 +1,66 @@ +# How to extend a Simple Schema + +## Background + +While Open Commerce uses a (mostly) schemaless database, we do value data validation and do that by using the package [SimpleSchema](https://www.npmjs.com/package/simpl-schema) which provides +sophisticated data type checking not available in something like traditional SQL schemas or something like JSON schema. However, when +adding functionality via plugins it can often be necessary to modify or extend these schemas to store extra data. This doc explains how to do that. + +## Determining if the schema you want to extend is extendable + +Generally there are two types of schemas public and private. Private schemas are schemas that are used by internal Reaction +processes and may change at any time therefore external plugins are discouraged or sometimes prevented from modifying them. +Public schemas are schemas that available to all plugins and are meant to be extended. You can see if a plugin is meant to be +public by seeing if the schema is added to the context via registration. If it is, it is meant to be public. For example in the built in +`api-plugin-orders` registration file you see where three schemas are made public (which would include a subschemas defined within) + +```js file=index.js + simpleSchemas: { + Order, + OrderFulfillmentGroup, + OrderItem + } +``` + +## Adding a field to an existing schema + +Probably the most common thing you need to do is add a field or fields to an existing schema. You do this by: + +1. Obtaining the existing schema from the context +2. Creating a new schema with the extra fields you need +3. Using the `extend` keyword on the existing schema + +For example say you want to add `purchaseOrderNumber` field to the `Order` schema. You would add a function in the startup sections of +your plugin registration file, and then in that file extend the schema. So your registration file might look something like this: + +```js title=index.js +import extendSchemas from "./startup/extendSchema.js"; + +export default async function register(app) { + await app.registerPlugin({ + label: "My Custom Plugin", + name: "custom-order-data", + version: pkg.version, + functionsByType: { + preStartup: [extendSchemas] + } + }); +} +``` + +Then the function in `extendSchemas.js` would look like this: + +```js title=extendSchemas.js +import SimpleSchema from "simpl-schema"; + +const purchaseOrderSchema = new SimpleSchema({ + purchaseOrderNumber: String +}); + +export default function extendSchemas(context) { + const { simpleSchemas: { Order } } = context; + Order.extend(purchaseOrderSchema); +} + ``` + +Since `context` here serves as a giant Singleton that is shared throughout the entire app, your now mutated schema is now available everywhere. diff --git a/how-tos/extend-graphql-to-add-field.md b/how-tos/extend-graphql-to-add-field.md new file mode 100644 index 0000000..530d632 --- /dev/null +++ b/how-tos/extend-graphql-to-add-field.md @@ -0,0 +1,94 @@ +# How To: Extend GraphQL to add a field + +Sometimes you only need to extend GraphQL to add a field to an existing type. Here's how to do it. + +## Extend the schema + +1. If it doesn't already exist, create `schemas` folder in the plugin, and add an `index.js` file there. +1. If it doesn't already exist, create `schema.graphql` in `schemas` in the plugin. +1. Import the GraphQL file into `index.js` and default export it in an array: + +```js +import schema from "./schema.graphql"; + +export default [schema]; +``` + + > NOTE: For large plugins, you can split to multiple `.graphql` files and export a multi-item array. + +1. In the `.graphql` file, use GraphQL language to extend the type with your custom field. + + ```graphql +extend type SomeType { + "Custom data for some purpose documented here" + myCustomField: String +} +``` + +1. Document all fields you add using string literals. See [Documenting a GraphQL Schema](../guides/developers-guide/core/developing-graphql.md#documenting-a-graphql-schema). +1. If not already done, register your schemas in the plugin's `register.js` file: + +```js +import schemas from "./schemas"; + +export default async function register(app) { + await app.registerPlugin({ + graphQL: { + schemas + }, + // other props + }); +} +``` + +## Create a field resolver if necessary + +If your field is not stored in the database in the same schema, such as if it is derived from other properties or collections or is stored in another system, then you'll also need to create a field resolver. + +### Create the resolver + +1. If it doesn't already exist, create `resolvers` folder in the plugin, and add an `index.js` file there. +3. In `resolvers`, create a file for the field resolver with the same name as the type the field is for, e.g. `Tag.js` if you extended the `Tag` type. The file should look something like this initially: + +```js +export default { + myCustomField(tag, args, context) { + return null; + } +}; +``` + +Replace `return null` with whatever logic you need to derive the custom field value and return it. You may make the resolver function `async` if necessary. + +> You have some freedom here to structure the `resolvers` folder in whatever way works best for your plugin. For an explanation and recommendations, refer to [Understanding the Resolvers File Structure](../guides/developers-guide/core/graphql-resolvers-file-structure.md) + +## Register the resolver + +In `resolvers/index.js` in the plugin, import your new file and add it to the default export object. Example: + +```js +import Tag from "./Tag" + +export default { + Tag +}; +``` + +If this is the first resolver for the plugin, pass the full `resolvers` object to `registerPlugin` in the plugin's `register.js` file: + +```js +import resolvers from "./resolvers"; +import schemas from "./schemas"; + +export default async function register(app) { + await app.registerPlugin({ + graphQL: { + resolvers, + schemas + }, + // other props + }); +} +``` + +You should now be able to query for your custom field using GraphQL. diff --git a/how-tos/extend-graphql-with-remote-schema.md b/how-tos/extend-graphql-with-remote-schema.md new file mode 100644 index 0000000..8fc3b1f --- /dev/null +++ b/how-tos/extend-graphql-with-remote-schema.md @@ -0,0 +1,77 @@ +# How To: Extend GraphQL with Remote Schema Delegation + +If you have an external service providing a GraphQL interface and you would like to make it available via the main Open Commerce GraphQL API, here's how to do it. For this example, we'll use the [public sample Pokemon GraphQL API](https://github.com/lucasbento/graphql-pokemon) from `https://graphql-pokemon.now.sh`. + +## Export your service's schema + +Use the [`get-graphql-schema`](https://www.npmjs.com/package/get-graphql-schema) command line utility from npm to generate the Schema Definition Language (SDL) text file you need. + +```sh +cat < pokemon.graphql +set -eu +npm install --silent --global get-graphql-schema >/dev/null +get-graphql-schema "https://graphql-pokemon.now.sh" +EOF +``` + +Incorporate that `pokemon.graphql` file into your plugin's directory structure. + +## Load your schema and link your service + +In your plugin's `register.js` file, load the schema and use the graphql-tools helper functions to generate a remote schema instance, which your plugin can then provide to Open Commerce. + +```js +import { + makeExecutableSchema, + makeRemoteExecutableSchema +} from "graphql-tools"; +import { HttpLink } from "apollo-link-http"; +import fetch from "node-fetch"; +import schemaSDL from "./pokemon.graphql"; + +const pokemonUrl = "https://graphql-pokemon.now.sh"; +const link = new HttpLink({ uri: pokemonUrl, fetch }); +const exSchema = makeExecutableSchema({ typeDefs: schemaSDL }); +const remoteSchema = makeRemoteExecutableSchema({ schema: exSchema, link }); + +export default async function register(app) { + await app.registerPlugin({ + label: "Pokemon", + name: "pokemon", + graphQL: { + schemas: [remoteSchema] + } + // other props + }); +} +``` + +## Verify your queries + +Once your plugin is loading properly, execute the additional queries from your remote service via the Reaction GraphQL endpoint. The queries will be delegated to your service and responses will be merged together. + +```graphql +# Submit this query via the reaction graphiql interface to verify +# the graphql schema delegation is working correctly +{ + pokemon(name: "Pikachu") { + id + name + attacks { + special { + name + type + damage + } + } + evolutions { + id + name + weight { + minimum + maximum + } + } + } +} +``` diff --git a/how-tos/how-do-i-misc.md b/how-tos/how-do-i-misc.md new file mode 100644 index 0000000..94848b7 --- /dev/null +++ b/how-tos/how-do-i-misc.md @@ -0,0 +1,140 @@ +# For Developers: How Do I...? + + +## Get the current authenticated user + +Use `context.userId` or `context.user` + +## Get the current authenticated account + +Use `context.accountId` or `context.account` + +### Using GraphQL + +```gql +{ + viewer { + _id + userId + } +} +``` + +## Check permissions for the current authenticated user + +```js +// In a query or mutation function: +await context.checkPermissions(["shipping"], shopId) +``` + +If the user has _any_ of the provided permissions, they will be allowed. Otherwise a `ReactionError` will be thrown. Be sure to pass in the correct shop ID, the ID of the shop that owns whatever entity is being fetched or changed. + + + +## Create a notification + +```js +await context.mutations.createNotification(context, { + accountId, + type: "orderCanceled", + url +}); +``` + + +## Loop over async function results + +Often you have a list of functions that return a Promise, and you need to loop through the list and call each function. The recommended way to do this depends on whether the functions are expecting to be called in series or can be safely called in parallel. + +For performance reasons, you should call them in parallel if you can. To do so, use `map` and await `Promise.all`. + +```js +const promisedResults = listOfFunctions.map((func) => func()); +const results = await Promise.all(promisedResults); +``` + +However, in some cases the functions have side effects that require them to be executed one after another, or you need to pass the result of function 1 into the function 2 call and so on. In these cases, you can use `await` within a `for` loop. The default `eslint` rule config disallows this, so it's a good idea to leave a detailed comment explaining why it's necessary. + +```js +// We need to run each of these functions in a series, rather than in parallel, because +// we are mutating the same object on each pass. It is recommended to disable `no-await-in-loop` +// eslint rules when the output of one iteration might be used as input in another iteration, such as this case here. +// See https://eslint.org/docs/rules/no-await-in-loop#when-not-to-use-it +for (const func of listOfFunctions) { + await func(product); // eslint-disable-line no-await-in-loop +} +``` + +## Work with countries + +To get a list of all countries with details about each: + +```js +import CountryDefinitions from "@reactioncommerce/api-utils/CountryDefinitions.js"; +``` + +To get array of country label and value, suitable for rendering a select in a user interface: + +```js +import CountryOptions from "@reactioncommerce/api-utils/CountryOptions.js"; +``` + +## Work with languages + +To get array of language label and value, suitable for rendering a select in a user interface: + +```js +import LanguageOptions from "@reactioncommerce/api-utils/LanguageOptions.js"; +``` + +## Work with currencies + +To get a list of all world currencies with details about each: + +```js +import CurrencyDefinitions from "@reactioncommerce/api-utils/CurrencyDefinitions.js"; +``` + +To get details about one currency when you know the currency code: + +```js +import getCurrencyDefinitionByCode from "@reactioncommerce/api-utils/getCurrencyDefinitionByCode.js"; + +const currencyDefinition = getCurrencyDefinitionByCode("USD"); +``` + +To get array of currency label and value, suitable for rendering a select in a user interface: + +```js +import CurrencyOptions from "@reactioncommerce/api-utils/CurrencyOptions.js"; +``` + +## Format money + +```js +import formatMoney from "@reactioncommerce/api-utils/formatMoney.js"; + +const formattedString = formatMoney(10.10, "EUR"); +``` + +This wraps the [accounting-js](https://www.npmjs.com/package/accounting-js) `formatMoney` function. + +## Generate and check an access token + +```js +import getAnonymousAccessToken from "@reactioncommerce/api-utils/getAnonymousAccessToken.js"; + +const tokenInfo = getAnonymousAccessToken(); +``` + +`tokenInfo` has `createdAt`, `hashedToken`, and `token` properties. `token` should be provided to the client or user who needs access. `createdAt` and `hashedToken` should be saved to the database. `token` must NOT be saved to the database. + +When `token` is later provided with an API request, you can compare it to the saved `hashedToken` like this: + +```js +import hashToken from "@reactioncommerce/api-utils/hashToken.js"; + +const { createdAt, hashedToken } = lookUpFromDatabase(); +// compare "now" to `createdAt` to determine if token is expired +// compare `hashedToken` with `hashToken(token)` to see if they are exactly equal +``` diff --git a/how-tos/run-function-on-startup.md b/how-tos/run-function-on-startup.md new file mode 100644 index 0000000..d1e7958 --- /dev/null +++ b/how-tos/run-function-on-startup.md @@ -0,0 +1,33 @@ +# How to Run plugin code on app startup +Copy the following into a startup.js file in the plugin folder: + +```js +/** + * @summary Called on startup + * @param {Object} context Startup context. This is the normal app context but without + * any information about the current request because there is no current request. + * @returns {undefined} + */ +export default function startup(context) { + // Plugin startup code here +} +``` + +Then import and register the startup function in the plugin's `index.js` file: + +```js +export default async function register(app) { + await app.registerPlugin({ + label: "Shipping", + name: "reaction-shipping", + functionsByType: { + startup: [startup] + } + // other props + }); +} +``` + +If you want to run certain code before startup functions run (for example to check if something exists before the full startup begins) +you can use `preStartup` the same way you use `startup`. If you want to run something **after** all startup functions have completed you +can use diff --git a/how-tos/store-custom-order-fields.md b/how-tos/store-custom-order-fields.md new file mode 100644 index 0000000..1e8fcf9 --- /dev/null +++ b/how-tos/store-custom-order-fields.md @@ -0,0 +1,109 @@ +# How To: Store Custom Order Fields + +## Prerequisite Reading +- [Understanding Plugins](../guides/developers-guide/core/build-api-plugin.md) +- [Extending Schemas](./extend-an-existing-schema.md) + +## Overview +For reporting, integrations, or other reasons, you may want to store additional fields when placing an order. This might be data from the client placing the order, data you can or must generate on the server, or some combination of the two. + +The core orders plugin supports attaching additional arbitrary fields to orders, but you must do a bit of work to enable it. The first step is to decide whether the data you need can safely come from clients or if it must be built on the server. An in-between approach is to send data from the client but validate and extend it on the server. + +Big picture, here's what you'll do: +1. Implement one or both of the following: + - Enable and configure clients to send custom order fields + - Generate or transform custom order fields on the server +2. Extend the SimpleSchema for `Order` so that your custom fields will pass validation and be saved. +3. Optionally expose some of your custom fields through GraphQL. + +## Enabling Clients to Send Additional Order Data +The only step necessary to allow clients to send additional order fields is to define the expected schema for it in GraphQL, which you do by extending the `OrderInput` GraphQL input type in a custom plugin to add a `customFields` field: + +```graphql +"Additional order fields" +input CustomOrderFieldsInput { + "The user agent string for the browser from which this order was placed" + browserUserAgent: String! +} + +extend input OrderInput { + "Additional order fields" + customFields: CustomOrderFieldsInput! +} +``` + +Now in your storefront code, you can add the now-required extra fields: + +```js +// `order` input object for `placeOrder` GraphQL mutation +const order = { + customFields: { + browserUserAgent: window.navigator.userAgent + }, + // all other required OrderInput props +}; +``` + +## Transforming or Adding Custom Order Fields on the Server +Whether or not you've allowed clients to send additional order data, you can also provide a function that returns custom order fields on the server. Do this using the `functionsByType` option in `registerPlugin`: + +```js +functionsByType: { + transformCustomOrderFields: [({ context, customFields, order }) => ({ + ...customFields, + // This is a simple example. IRL you should pick only headers you need. + headers: context.requestHeaders + })] +}, +``` + +As you can see in the example, the function receives an object argument with `context`, `customFields`, and `order` properties. `order` is the full order that is about to be created. `customFields` is the current custom fields object that would be saved as `order.customFields`. This may be from the client placing the order, if you've allowed clients to send it, or it may be from `transformCustomOrderFields` functions registered by other plugins. It will be an empty object if no other `transformCustomOrderFields` functions have run and the client did not pass any custom fields. + +The object you return will replace `order.customFields`, so if you want to keep the properties already in `customFields`, be sure to add them to the object you return, or simply `return customFields` if you have no changes to make. You may also further validate the object and either remove properties from it or throw an error. + +`transformCustomOrderFields` functions may be `async` if necessary. + +## Extending the Order Schema +If you are adding custom fields either from clients or from server functions, you must also extend the SimpleSchema for `Order` so that it validates for storage. This is similar to the "Enabling Clients to Send Additional Order Data" task, but you must extend the SimpleSchema rather than the GraphQL schema, and it's required even if all data is being generated on the server. In your custom plugin +you should add a startup function that modifies the schema as it's stored on the `context` object. + +```js title=startup.js +const customFieldsSchema = new SimpleSchema({ + browserUserAgent: String +}); + +export default async function extendOrderSchema(context) { + const { simpleSchemas: { Order } } = context; + Order.extend({ + customFields: customFieldsSchema + }); +} +``` + +## Exposing Custom Fields Through GraphQL +If you are collecting extra order fields only for reporting or integration purposes, it may not be necessary to have them available through GraphQL queries. But if you do need some fields added to orders retrieved by GraphQL, it is easy to do this by extending the schema and adding resolvers in your custom plugin: + +```graphql +extend type Order { + "The user agent string for the browser from which this order was placed" + browserUserAgent: String! +} +``` + +```js +const resolvers = { + Order: { + browserUserAgent: (node) => node.customFields.browserUserAgent + } +}; +``` + +And then query for it: + +``` +{ + orderById(id: "123", shopId: "456", token: "abc") { + browserUserAgent + } +} +```