Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/Modern-GraphQLInRelay.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,33 @@ Will cause a generated file to appear in `./__generated__/MyComponent.graphql`,
with both runtime artifacts (which help to read and write from the Relay Store)
and [Flow types](https://flow.org/) to help you write type-safe code.

The Relay Compiler is responsible for generating code as part of a build step which, at runtime, can be used statically. By building the query ahead of time, the client's JS runtime is not responsible for generating a query string, and fields that are duplicated in the query can be merged during the build step, to improve parsing efficiency. If you have the ability to persist queries to your server, the compiler's code generation process provides a convenient time to convert a query or mutation's text into a unique identifier, which can greatly reduce the upload bytes required in some applications.
The Relay Compiler is responsible for generating code as part of a build step which, at runtime, can be used statically. By building the query ahead of time, the client's JS runtime is not responsible for generating a query string, and fields that are duplicated in the query can be merged during the build step, to improve parsing efficiency.

### Persisting queries
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to add a paragraph maybe about why someone would want to persist queries. There is both the performance benefits, and, security benefits if you use the persisted queries as a query whitelist.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Done.

The Relay Compiler can convert a query or mutation's text into a unique identifier during compilation. This can greatly reduce the upload bytes required in some applications.
Using a unique identifier in place of the query text means you have a well-known list of queries that the client can send instead of any
arbitrary query. This technique of allowing only known queries to be run on the server is called query whitelisting. Whitelisting prevents malicious attacks
such as:

* an internal dos attack where the query depth is high and exploits circular relationships in the schema
* a full schema introspection which might not be desirable
* a harmful pagination query that fetches a million objects

For more information on whitelisting and securing your graphql api from attacks, please refer to this [excellent blog by Max Stoiber](https://dev-blog.apollodata.com/securing-your-graphql-api-from-malicious-queries-16130a324a6b).

The Relay Compiler can persist your queries with the `--persist` flag:

```js
"scripts": {
"relay": "relay-compiler --src ./src --schema ./schema.graphql --persist"
}
```

This will create a matching `./__generated__/MyComponent.queryMap.json` containing the query id and the operation text of the query in the same directory.
The Relay Compiler aggregates all the generated `*.queryMap.json` files into a single complete query map file at `./src/complete.queryMap.json`. You can then use this complete
json file in your server side to map query ids to operation text.
Copy link
Copy Markdown
Contributor

@alloy alloy Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you maybe add some details about where this would typically sit in one’s workflow?

For instance, I’m naively thinking that you’d only use the --persist option once you decide to cut a release/perform a deploy, seeing as the server needs to know about these queries and you probably don’t want to continuously do that, is that correct?

It would also be nice to have an example of what you do with the resulting query map file, i.e. describe uploading to server and serving requests that use query IDs. (This could maybe even be added to the canonical example app?)

Copy link
Copy Markdown
Contributor Author

@yusinto yusinto Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My use case is explained in my blog post here. My app is universal and I have hot reload setup on both the client and server. The express server imports the complete queryMap.json file and uses a middleware to match queries from graphql requests.

During development, I change my graphql queries a lot (in relay graphql tags). Each time this happens the operation text changes and hence the query id changes because it is an md5 hash of the operation text. The graphql.js and queryMap.json files get recompiled, and my express server reloads the queryMap.json and is able to continue mapping queries correctly without having to stop/start.

The blog contains details about the express middleware and a complete working example. Hopefully the example can shed more light into this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha.

I understand that in such a situation you basically have the JSON file in the same project and so it’s easy to continuously write the persisted queries, but in setups where the server is a different project altogether it doesn’t seem necessary to do this continuously. What’s more, in the situation I’m describing there isn’t even a good way to persist the queries on the server during development and running relay-compiler --watch.

As such, I think it would be great if persisted queries gets its own doc page where you could both include the example from your blog post for a universal app that includes the server and has a more agnostic example of a separate server that anybody can adjust to their situation.

Copy link
Copy Markdown

@MatthewHerbst MatthewHerbst Mar 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have to agree with @alloy here. We have three client repos that each have their own queries, and then a 4th repo for our gql server. We'd likely set up a 5th repo to store persisted queries in (which would also be reflected in a DB).

Copy link
Copy Markdown
Contributor Author

@yusinto yusinto Mar 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that in such a situation you basically have the JSON file in the same project and so it’s easy to continuously write the persisted queries, but in setups where the server is a different project altogether it doesn’t seem necessary to do this continuously. What’s more, in the situation I’m describing there isn’t even a good way to persist the queries on the server during development and running relay-compiler --watch.

Right I got your point. So there are 2 ways to use --persist:

  1. As a single run with --persist which will produce a master queryMap.json file. This does a one time compilation and stops. It doesn't do any continuous watching or compiling.

  2. In combination with --watch. If you specify --persist and --watch, then queryMap.json will continuously update with the changes you make to your queries.

If you don't need continuous updates to queryMap.json, you can just do a single run like in step 1. The watch mode is useful if like me you have the luxury of having a universal app where the server and client are in one project.

In the case where the server is a separate project and you run --persist either as a single run or in watch mode in the client, you'll still need to somehow deploy the query map file to your server. In the simplest case, this can be a git push to your server repo to update the server query map file. In more complex scenarios this can be a build step which does database updates.

As such, I think it would be great if persisted queries gets its own doc page where you could both include the example from your blog post for a universal app that includes the server and has a more agnostic example of a separate server that anybody can adjust to their situation.

That's a great suggestion. I'll document what I've said here and in my blog post in a standalone doc page specifically for persisted queries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As such, I think it would be great if persisted queries gets its own doc page where you could both include the example from your blog post for a universal app that includes the server and has a more agnostic example of a separate server that anybody can adjust to their situation.

Done.


For more details, refer to the [Persisted Queries section](./persisted-queries.html).

### Set up relay-compiler

Expand Down Expand Up @@ -222,6 +248,11 @@ This would produce three generated files, and two `__generated__` directories:
* `src/Components/__generated__/DictionaryComponent_definition.graphql.js`
* `src/Queries/__generated__/DictionaryQuery.graphql.js`

If you use `--persist`, then an extra query map json file will also be generated:

* `src/Queries/__generated__/DictionaryQuery.queryMap.json`

Only one query map json file is generated in this instance because only concrete queries can be persisted. Fragments are not persisted.

### Importing generated definitions

Expand Down
164 changes: 164 additions & 0 deletions docs/Modern-PersistedQueries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
---
id: persisted-queries
title: Persisted Queries
---

The relay compiler supports persisted queries which is useful because:

* the client operation text becomes just an md5 hash which is usually shorter than the real
query string. This saves upload bytes from the client to the server.

* the server can now whitelist queries which improves security by restricting the operations
that can be run from the client.

## Usage on the client

### The `--persist` flag
In your `npm` script in `package.json`, run the relay compiler using the `--persist` flag:

```js
"scripts": {
"relay": "relay-compiler --src ./src --schema ./schema.graphql --persist"
}
```

The `--persist` flag does 3 things:

1. It converts all query and mutation operation texts to md5 hashes.

For example without `--persist`, a generated `ConcreteRequest` might look like below:

```js
const node/*: ConcreteRequest*/ = (function(){
//... excluded for brevity
return {
"kind": "Request",
"operationKind": "query",
"name": "TodoItemRefetchQuery",
"id": null, // NOTE: id is null
"text": "query TodoItemRefetchQuery(\n $itemID: ID!\n) {\n node(id: $itemID) {\n ...TodoItem_item_2FOrhs\n }\n}\n\nfragment TodoItem_item_2FOrhs on Todo {\n text\n isComplete\n}\n",
//... excluded for brevity
};
})();
```

With `--persist` this becomes:

```js
const node/*: ConcreteRequest*/ = (function(){
//... excluded for brevity
return {
"kind": "Request",
"operationKind": "query",
"name": "TodoItemRefetchQuery",
"id": "3be4abb81fa595e25eb725b2c6a87508", // NOTE: id is now an md5 hash of the query text
"text": null, // NOTE: text is null now
//... excluded for brevity
};
})();
```

2. It generates a matching `.queryMap.json` file containing a map of the id and the operation text in the same `__generated__`
directory as the `.graphql.js` file. In the example above, the `__generated__` directory will have these files:

* `./__generated__/TodoItemRefetchQuery.graphql.js`
* `./__generated__/TodoItemRefetchQuery.queryMap.json`

The `.queryMap.json` file looks something like this:

```json
{
"3be4abb81fa595e25eb725b2c6a87508": "query TodoItemRefetchQuery(\n $itemID: ID!\n) {\n node(id: $itemID) {\n ...TodoItem_item_2FOrhs\n }\n}\n\nfragment TodoItem_item_2FOrhs on Todo {\n text\n isComplete\n}\n"
}
```

3. It also generates a complete query map file at `[your_src_dir]/complete.queryMap.json`. This file contains all the query ids
and their operation texts. You can specify a custom file path for this file by using the `--persist-output` option:

```js
"scripts": {
"relay": "relay-compiler --src ./src --schema ./schema.graphql --persist --persist-output ./src/queryMaps/queryMap.desktop.queryMap.json"
}
```

The example above writes the complete query map file to `./src/queryMaps/queryMap.desktop.queryMap.json`. You need to ensure all the directories
leading to the `queryMap.json` file exist. Also note that the file extension has to be `.json`.

### Network layer changes
You'll need to modify your network layer fetch implementation to pass a documentId parameter in the POST body instead of a query parameter:

```js
function fetchQuery(operation, variables,) {
return fetch('/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
documentId: operation.id, // NOTE: pass md5 hash to the server
// query: operation.text, // this is now obsolete because text is null
variables,
}),
}).then(response => {
return response.json();
});
}
```

## Usage on the server
On the server, you'll need to map the query id in the POST body to the real operation text. You can utilise the
`complete.queryMap.json` file to do this so you'll need a copy of it on your server.

For universal applications where the client and server code are in one project, this is not an issue since you can place
the query map file in a common location accessible to both the client and the server.

### Compile time push
For applications where the client and server projects are separate, one option is to have an additional npm run script
to push the query map at compile time to a location accessible by your server:

```js
"scripts": {
"push-queries": "node ./pushQueries.js",
"relay": "relay-compiler --src ./src --schema ./schema.graphql --persist && npm run push-queries"
}
```

Some possibilities of what you can do in `./pushQueries.js`:

* `git push` to your server repo

* save the query maps to a database

### Run time push
A second more complex option is to push your query maps to the server at runtime, without the server knowing the query ids at the start.
The client optimistically sends a query id to the server, which does not have the query map. The server then in turn requests
for the full query text from the client so it can cache the query map for subsequent requests. This is a more complex approach
requiring the client and server to interact to exchange the query maps.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be another entry in the list of options, rather than a separate paragraph?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have re-organised the server push options to have a compile time and a run time section. Optimistic query is a run-time solution and so is separate from the npm run option which is a compile time solution. Please have a look and let me know if this looks ok. Thanks @alloy.


### Simple server example
Once your server has access to the query map, you can perform the mapping. The solution varies depending on the server and
database technologies you use, so we'll just cover the most common and basic example here.

If you use `express-graphql` and have access to the query map file, you can import the `complete.queryMap.json` file directly and
perform the matching using the `matchQueryMiddleware` from [relay-compiler-plus](https://github.com/yusinto/relay-compiler-plus).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this repo going to continue to exist once this feature would be merged and released?

Copy link
Copy Markdown
Contributor Author

@yusinto yusinto Mar 14, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I am actively maintaining this repo and plan to do so indefinitely. It's a completely separate repo unrelated to this feature.


```js
import Express from 'express';
import expressGraphql from 'express-graphql';
import {matchQueryMiddleware} from 'relay-compiler-plus';
import queryMapJson from './complete.queryMap.json';

const app = Express();

app.use('/graphql',
matchQueryMiddleware(queryMapJson),
expressGraphl({schema}));
```

## Using `--persist` and `--watch`
It is possible to continuously generate the query map files by using the `--persist` and `--watch` options simultaneously.
This only makes sense for universal applications i.e. if your client and server code are in a single project
and you run them both together on localhost during development. Furthermore, in order for the server to pick up changes
to the `queryMap.json`, you'll need to have server side hot-reloading set up. The details on how to set this up
is out of the scope of this document.

41 changes: 39 additions & 2 deletions packages/relay-compiler/bin/RelayCompilerBin.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
const RelaySourceModuleParser = require('../core/RelaySourceModuleParser');
const RelayFileWriter = require('../codegen/RelayFileWriter');
const RelayIRTransforms = require('../core/RelayIRTransforms');
const persistQuery = require('../codegen/persistQuery');
const RelayLanguagePluginJavaScript = require('../language/javascript/RelayLanguagePluginJavaScript');

const fs = require('fs');
Expand Down Expand Up @@ -139,6 +140,8 @@ async function run(options: {
watch?: ?boolean,
validate: boolean,
quiet: boolean,
persist: boolean,
'persist-output': string,
noFutureProofEnums: boolean,
language: string,
artifactDirectory: ?string,
Expand All @@ -149,7 +152,26 @@ async function run(options: {
}
const srcDir = path.resolve(process.cwd(), options.src);
if (!fs.existsSync(srcDir)) {
throw new Error(`--source path does not exist: ${srcDir}.`);
throw new Error(`--src path does not exist: ${srcDir}.`);
}

const persist = options.persist;
let persistOutput = options['persist-output'];
if (persistOutput) {
persistOutput = path.resolve(process.cwd(), persistOutput);
const persistOutputDir = path.dirname(persistOutput);
if (!fs.existsSync(persistOutputDir)) {
throw new Error(
`--persist-output path does not exist: ${persistOutputDir}.`,
Copy link
Copy Markdown

@MatthewHerbst MatthewHerbst Mar 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to mention this option in the docs with/near --persist

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Will update the docs. Thank you.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

);
}

const persistOutputFileExtension = path.extname(persistOutput);
if (persistOutputFileExtension !== '.json') {
throw new Error(
`--persist-output must be a path to a .json file: ${persistOutput}.`,
);
}
}
if (options.watch && !options.watchman) {
throw new Error('Watchman is required to watch for changes.');
Expand Down Expand Up @@ -244,9 +266,12 @@ Ensure that one such file exists in ${srcDir} or its parents.
languagePlugin,
options.noFutureProofEnums,
artifactDirectory,
persist,
persistOutput,
),
isGeneratedFile: (filePath: string) =>
filePath.endsWith('.graphql.' + outputExtension) &&
(filePath.endsWith('.graphql.' + outputExtension) ||
filePath.endsWith('.queryMap.json')) &&
filePath.includes(generatedDirectoryName),
parser: sourceParserName,
baseParsers: ['graphql'],
Expand Down Expand Up @@ -281,6 +306,8 @@ function getRelayFileWriter(
languagePlugin: PluginInterface,
noFutureProofEnums: boolean,
outputDir?: ?string,
persist: boolean,
persistOutput: string,
) {
return ({
onlyValidate,
Expand All @@ -305,6 +332,8 @@ function getRelayFileWriter(
optionalInputFieldsForFlow: [],
schemaExtensions,
useHaste: false,
persistQuery: persist ? persistQuery : undefined,
persistOutput,
noFutureProofEnums,
extension: languagePlugin.outputExtension,
typeGenerator: languagePlugin.typeGenerator,
Expand Down Expand Up @@ -426,6 +455,14 @@ const argv = yargs
type: 'boolean',
default: false,
},
persist: {
describe: 'Use an md5 hash as query id to replace operation text',
type: 'boolean',
},
'persist-output': {
describe:
'The json filepath where the complete query map file will be written to',
},
noFutureProofEnums: {
describe:
'This option controls whether or not a catch-all entry is added to enum type definitions ' +
Expand Down
89 changes: 89 additions & 0 deletions packages/relay-compiler/bin/__tests__/RelayCompilerBin-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @emails oncall+relay
*/

jest.mock('graphql-compiler');
jest.mock('yargs');
jest.mock('@babel/polyfill');

require('graphql-compiler');
const yargs = require('yargs');

describe('RelayCompilerBin', () => {
let originalProcessExit;
let originalConsoleError;

const mockCliArguments = (schema = './', src = './', persistOutput = './') => {
yargs.usage.mockImplementation(() => ({
options: () => ({
help: () => ({
argv: {schema, src, 'persist-output': persistOutput},
}),
}),
})
);
};

beforeEach(() => {
jest.resetModules();
originalProcessExit = process.exit;
originalConsoleError = console.error;
process.exit = jest.fn();
console.error = jest.fn();
});

afterEach(() => {
process.exit = originalProcessExit;
console.error = originalConsoleError;
});

test('should throw error when schema path does not exist', async () => {
mockCliArguments('./some/path/schema.graphql');

await require('../RelayCompilerBin');

expect(console.error).toBeCalled();
expect(console.error.mock.calls[0][0]).toEqual(expect.stringContaining('Error: --schema path does not exist:'));
expect(process.exit).toBeCalled();
expect(process.exit.mock.calls[0][0]).toEqual(1);
});

test('should throw error when src path does not exist', async () => {
mockCliArguments('./', './some/path/src');

await require('../RelayCompilerBin');

expect(console.error).toBeCalled();
expect(console.error.mock.calls[0][0]).toEqual(expect.stringContaining('Error: --src path does not exist:'));
expect(process.exit).toBeCalled();
expect(process.exit.mock.calls[0][0]).toEqual(1);
});

test('should throw error when persist-output path does not exist', async () => {
mockCliArguments('./', './', 'some/path/complete.queryMap.json');

await require('../RelayCompilerBin');

expect(console.error).toBeCalled();
expect(console.error.mock.calls[0][0]).toEqual(expect.stringContaining('Error: --persist-output path does not exist:'));
expect(process.exit).toBeCalled();
expect(process.exit.mock.calls[0][0]).toEqual(1);
});

test('should throw error when persist-output path does not end with a .json extension', async () => {
mockCliArguments('./', './', './queryMap.graphql.js');

await require('../RelayCompilerBin');

expect(console.error).toBeCalled();
expect(console.error.mock.calls[0][0]).toEqual(expect.stringContaining('Error: --persist-output must be a path to a .json file'));
expect(process.exit).toBeCalled();
expect(process.exit.mock.calls[0][0]).toEqual(1);
});
});
Loading