diff --git a/docs/Modern-GraphQLInRelay.md b/docs/Modern-GraphQLInRelay.md index 9ff5514c43b3c..98315d7b61674 100644 --- a/docs/Modern-GraphQLInRelay.md +++ b/docs/Modern-GraphQLInRelay.md @@ -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 +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. + +For more details, refer to the [Persisted Queries section](./persisted-queries.html). ### Set up relay-compiler @@ -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 diff --git a/docs/Modern-PersistedQueries.md b/docs/Modern-PersistedQueries.md new file mode 100644 index 0000000000000..dc9945e307944 --- /dev/null +++ b/docs/Modern-PersistedQueries.md @@ -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. + +### 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). + +```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. + diff --git a/packages/relay-compiler/bin/RelayCompilerBin.js b/packages/relay-compiler/bin/RelayCompilerBin.js index 95375182612b8..c183d13ea4106 100644 --- a/packages/relay-compiler/bin/RelayCompilerBin.js +++ b/packages/relay-compiler/bin/RelayCompilerBin.js @@ -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'); @@ -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, @@ -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}.`, + ); + } + + 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.'); @@ -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'], @@ -281,6 +306,8 @@ function getRelayFileWriter( languagePlugin: PluginInterface, noFutureProofEnums: boolean, outputDir?: ?string, + persist: boolean, + persistOutput: string, ) { return ({ onlyValidate, @@ -305,6 +332,8 @@ function getRelayFileWriter( optionalInputFieldsForFlow: [], schemaExtensions, useHaste: false, + persistQuery: persist ? persistQuery : undefined, + persistOutput, noFutureProofEnums, extension: languagePlugin.outputExtension, typeGenerator: languagePlugin.typeGenerator, @@ -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 ' + diff --git a/packages/relay-compiler/bin/__tests__/RelayCompilerBin-test.js b/packages/relay-compiler/bin/__tests__/RelayCompilerBin-test.js new file mode 100644 index 0000000000000..efbee675d2c8c --- /dev/null +++ b/packages/relay-compiler/bin/__tests__/RelayCompilerBin-test.js @@ -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); + }); +}); diff --git a/packages/relay-compiler/codegen/RelayFileWriter.js b/packages/relay-compiler/codegen/RelayFileWriter.js index a134be526f31c..b426f3d3a9f6e 100644 --- a/packages/relay-compiler/codegen/RelayFileWriter.js +++ b/packages/relay-compiler/codegen/RelayFileWriter.js @@ -14,10 +14,12 @@ const RelayParser = require('../core/RelayParser'); const RelayValidator = require('../core/RelayValidator'); const compileRelayArtifacts = require('./compileRelayArtifacts'); -const crypto = require('crypto'); const graphql = require('graphql'); const invariant = require('invariant'); const path = require('path'); +const fs = require('fs'); +const md5 = require('../util/md5'); + const writeRelayGeneratedFile = require('./writeRelayGeneratedFile'); const { @@ -58,6 +60,7 @@ export type WriterConfig = { outputDir?: ?string, generatedDirectories?: Array, persistQuery?: (text: string) => Promise, + persistOutput?: string, platform?: string, schemaExtensions: Array, noFutureProofEnums: boolean, @@ -73,11 +76,6 @@ export type WriterConfig = { }, // EXPERIMENTAL: skips deleting extra files in the generated directories experimental_noDeleteExtraFiles?: boolean, - // EXPERIMENTAL: skips deleting extra files with the supplied pattern in - // the generated directories. - // TODO (T35012551): Remove this when no longer necessary with a better - // directory structure. - experimental_extraFilesPatternToKeep?: RegExp, }; function compileAll({ @@ -166,6 +164,45 @@ function compileAll({ }; } +/** + * Find all *.queryMap.json and write it into a single file. + * @param allOutputDirectories + */ +function writeCompleteQueryMap({ + allOutputDirectories, + config: writerConfig, + reporter, +}: {| + allOutputDirectories: Map, + config: WriterConfig, + reporter: Reporter, +|}): void { + const queryMapFilePath = + writerConfig.persistOutput || + `${writerConfig.baseDir}/complete.queryMap.json`; + try { + let queryMapJson = {}; + allOutputDirectories.forEach(d => { + fs.readdirSync(d._dir).forEach(f => { + if (f.endsWith('.queryMap.json')) { + const singleQueryMap = JSON.parse( + fs.readFileSync(path.join(d._dir, f), 'utf8'), + ); + queryMapJson = { + ...queryMapJson, + ...singleQueryMap, + }; + } + }); + }); + + fs.writeFileSync(queryMapFilePath, JSON.stringify(queryMapJson, null, 2)); + reporter.reportMessage(`Complete queryMap written to ${queryMapFilePath}`); + } catch (err) { + reporter.reportError('RelayFileWriter.writeCompleteQueryMap', err); + } +} + function writeAll({ config: writerConfig, onlyValidate, @@ -360,16 +397,23 @@ function writeAll({ // clean output directories if (writerConfig.experimental_noDeleteExtraFiles !== true) { allOutputDirectories.forEach(dir => { - dir.deleteExtraFiles( - writerConfig.experimental_extraFilesPatternToKeep, - ); + dir.deleteExtraFiles(); }); - } - if (sourceControl && !onlyValidate) { - await CodegenDirectory.sourceControlAddRemove( - sourceControl, - Array.from(allOutputDirectories.values()), - ); + + if (writerConfig.persistQuery) { + writeCompleteQueryMap({ + allOutputDirectories, + config: writerConfig, + reporter, + }); + } + + if (sourceControl && !onlyValidate) { + await CodegenDirectory.sourceControlAddRemove( + sourceControl, + Array.from(allOutputDirectories.values()), + ); + } } } catch (error) { let details; @@ -383,18 +427,10 @@ function writeAll({ 'Error writing modules:\n' + String(error.stack || error), ); } - return allOutputDirectories; }); } -function md5(x: string): string { - return crypto - .createHash('md5') - .update(x, 'utf8') - .digest('hex'); -} - module.exports = { writeAll, }; diff --git a/packages/relay-compiler/codegen/__tests__/persistQuery-test.js b/packages/relay-compiler/codegen/__tests__/persistQuery-test.js new file mode 100644 index 0000000000000..f490cf3b71dc4 --- /dev/null +++ b/packages/relay-compiler/codegen/__tests__/persistQuery-test.js @@ -0,0 +1,40 @@ +/** + * 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. + * + * @flow + * @format + */ + +jest.mock('../../util/md5'); +const md5 = require('../../util/md5'); +const persistQuery = require('../persistQuery'); + +describe('persistQuery', () => { + const animalQuery = 'query { animal }'; + const humanQuery = 'query { human }'; + + md5.mockImplementation(query => { + if (query === animalQuery) { + return 'animalMd5'; + } else if (query === humanQuery) { + return 'humanMd5'; + } + return 'unknownMd5'; + }); + + test('should hash and store query correctly', async () => { + const documentId = await persistQuery(animalQuery); + expect(documentId).toEqual('animalMd5'); + }); + + test('should hash and store all queries correctly', async () => { + const documentId1 = await persistQuery(animalQuery); + const documentId2 = await persistQuery(humanQuery); + + expect(documentId1).toEqual('animalMd5'); + expect(documentId2).toEqual('humanMd5'); + }); +}); \ No newline at end of file diff --git a/packages/relay-compiler/codegen/__tests__/writeRelayGeneratedFile-test.js b/packages/relay-compiler/codegen/__tests__/writeRelayGeneratedFile-test.js new file mode 100644 index 0000000000000..128a461067d72 --- /dev/null +++ b/packages/relay-compiler/codegen/__tests__/writeRelayGeneratedFile-test.js @@ -0,0 +1,146 @@ +/** + * 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. + * + * @flow + * @format + */ + +import persistQuery from '../persistQuery'; + +const writeRelayGeneratedFile = require('../writeRelayGeneratedFile'); + +describe('writeRelayGeneratedFile', () => { + describe('persisted queries', () => { + let codeGenDir; + const formatModule = () => 'mockFormatModuleOuput'; + const flowTypes = ''; + const platform = null; + const sourceHash = 'test-hash'; + const extension = 'js'; + + beforeEach(() => { + codeGenDir = { + read: () => 'oldContent', + writeFile: jest.fn(), + markUnchanged: jest.fn(), + markUpdated: jest.fn(), + }; + }); + + test('should persist concrete request', async () => { + + const node = { + kind: 'Request', + operationKind: 'query', + name: 'summaryBar_refetch_Query', + text: 'query product_refetch_Query { viewer { product } }', + }; + const expectedDocumentId = await persistQuery(node.text); + + const generatedNode = await writeRelayGeneratedFile( + codeGenDir, + node, + formatModule, + flowTypes, + persistQuery, + platform, + sourceHash, + extension + ); + + expect(codeGenDir.markUnchanged).not.toBeCalled(); + expect(codeGenDir.markUpdated).not.toBeCalled(); + expect(generatedNode.id).toEqual(expectedDocumentId); + expect(generatedNode.text).toBeNull(); + expect(codeGenDir.writeFile.mock.calls.length).toEqual(2); + expect(codeGenDir.writeFile.mock.calls[0][0]).toBe('summaryBar_refetch_Query.graphql.js'); + expect(codeGenDir.writeFile.mock.calls[0][1]).toBe('mockFormatModuleOuput'); + expect(codeGenDir.writeFile).lastCalledWith('summaryBar_refetch_Query.queryMap.json', `{ + \"${expectedDocumentId}\": \"${node.text}\" +}`); + }); + + test('should not persist fragment', async () => { + const node = { + kind: 'Fragment', + name: 'summaryBar_refetch_Query', + }; + + const generatedNode = await writeRelayGeneratedFile( + codeGenDir, + node, + formatModule, + flowTypes, + persistQuery, + platform, + sourceHash, + extension + ); + + expect(codeGenDir.markUnchanged).not.toBeCalled(); + expect(codeGenDir.markUpdated).not.toBeCalled(); + expect(generatedNode.id).toBeUndefined(); + expect(generatedNode.text).toBeUndefined(); + expect(codeGenDir.writeFile.mock.calls.length).toEqual(1); + expect(codeGenDir.writeFile).lastCalledWith('summaryBar_refetch_Query.graphql.js', 'mockFormatModuleOuput'); + }); + + test('should mark queryMap.json as unchanged if hash is unchanged', async () => { + jest.doMock('crypto', () => ({createHash: () => ({update: () => '', digest: () => null})})); + + const node = { + kind: 'Request', + operationKind: 'query', + name: 'summaryBar_refetch_Query', + text: 'query product_refetch_Query { viewer { product } }', + }; + + await writeRelayGeneratedFile( + codeGenDir, + node, + formatModule, + flowTypes, + persistQuery, + platform, + sourceHash, + extension + ); + + expect(codeGenDir.markUnchanged.mock.calls.length).toEqual(2); + expect(codeGenDir.markUpdated).not.toBeCalled(); + expect(codeGenDir.markUnchanged.mock.calls[0][0]).toBe('summaryBar_refetch_Query.graphql.js'); + expect(codeGenDir.markUnchanged).lastCalledWith('summaryBar_refetch_Query.queryMap.json'); + }); + + test('should mark queryMap.json as updated when only validating', async () => { + jest.unmock('crypto'); + codeGenDir.onlyValidate = true; + + const node = { + kind: 'Request', + operationKind: 'query', + name: 'summaryBar_refetch_Query', + text: 'query product_refetch_Query { viewer { product } }', + }; + + await writeRelayGeneratedFile( + codeGenDir, + node, + formatModule, + flowTypes, + persistQuery, + platform, + sourceHash, + extension + ); + + expect(codeGenDir.markUnchanged).not.toBeCalled(); + expect(codeGenDir.markUpdated.mock.calls.length).toEqual(2); + expect(codeGenDir.markUpdated.mock.calls[0][0]).toBe('summaryBar_refetch_Query.graphql.js'); + expect(codeGenDir.markUpdated).lastCalledWith('summaryBar_refetch_Query.queryMap.json'); + }); + }); +}); \ No newline at end of file diff --git a/packages/relay-compiler/codegen/persistQuery.js b/packages/relay-compiler/codegen/persistQuery.js new file mode 100644 index 0000000000000..0a38e7efea146 --- /dev/null +++ b/packages/relay-compiler/codegen/persistQuery.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @providesModule persistQuery + * @flow + * @format + */ + +'use strict'; + +import md5 from '../util/md5'; + +const persistQuery = (operationText: string): Promise => { + return new Promise(resolve => resolve(md5(operationText))); +}; + +module.exports = persistQuery; diff --git a/packages/relay-compiler/codegen/writeRelayGeneratedFile.js b/packages/relay-compiler/codegen/writeRelayGeneratedFile.js index c8948eb9bebbf..ace11ca4fe4e6 100644 --- a/packages/relay-compiler/codegen/writeRelayGeneratedFile.js +++ b/packages/relay-compiler/codegen/writeRelayGeneratedFile.js @@ -27,16 +27,17 @@ async function writeRelayGeneratedFile( generatedNode: GeneratedNode, formatModule: FormatModule, typeText: string, - _persistQuery: ?(text: string) => Promise, + persistQuery: ?(text: string) => Promise, platform: ?string, sourceHash: string, extension: string, ): Promise { // Copy to const so Flow can refine. - const persistQuery = _persistQuery; + const _persistQuery = persistQuery; const moduleName = generatedNode.name + '.graphql'; const platformName = platform ? moduleName + '.' + platform : moduleName; const filename = platformName + '.' + extension; + const queryMapFilename = `${generatedNode.name}.queryMap.json`; const typeName = generatedNode.kind === RelayConcreteNode.FRAGMENT ? 'ConcreteFragment' @@ -51,6 +52,8 @@ async function writeRelayGeneratedFile( } let hash = null; + let queryMap = null; + if (generatedNode.kind === RelayConcreteNode.REQUEST) { const oldHash = Profiler.run('RelayFileWriter:compareHash', () => { const oldContent = codegenDir.read(filename); @@ -62,7 +65,7 @@ async function writeRelayGeneratedFile( if (typeText) { hasher.update(typeText); } - if (persistQuery) { + if (_persistQuery) { hasher.update('persisted'); } hash = hasher.digest('hex'); @@ -70,20 +73,32 @@ async function writeRelayGeneratedFile( }); if (hash === oldHash) { codegenDir.markUnchanged(filename); + + if (_persistQuery) { + codegenDir.markUnchanged(queryMapFilename); + } return null; } if (codegenDir.onlyValidate) { codegenDir.markUpdated(filename); + + if (_persistQuery) { + codegenDir.markUpdated(queryMapFilename); + } return null; } - if (persistQuery) { + if (_persistQuery) { switch (generatedNode.kind) { case RelayConcreteNode.REQUEST: - devOnlyProperties.text = generatedNode.text; + const operationText = generatedNode.text; + devOnlyProperties.text = operationText; + const documentId = await _persistQuery(nullthrows(operationText)); + queryMap = {}; + queryMap[documentId] = operationText; generatedNode = { ...generatedNode, text: null, - id: await persistQuery(nullthrows(generatedNode.text)), + id: documentId, }; break; case RelayConcreteNode.FRAGMENT: @@ -109,6 +124,10 @@ async function writeRelayGeneratedFile( }); codegenDir.writeFile(filename, moduleText); + if (_persistQuery && queryMap) { + codegenDir.writeFile(queryMapFilename, JSON.stringify(queryMap, null, 2)); + } + return generatedNode; } diff --git a/packages/relay-compiler/util/md5.js b/packages/relay-compiler/util/md5.js new file mode 100644 index 0000000000000..35684b538b256 --- /dev/null +++ b/packages/relay-compiler/util/md5.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @providesModule md5 + * @flow + * @format + */ + +'use strict'; + +import crypto from 'crypto'; + +const md5 = (x: string): string => { + return crypto + .createHash('md5') + .update(x, 'utf8') + .digest('hex'); +}; + +module.exports = md5; diff --git a/website/sidebars.json b/website/sidebars.json index 85000b2ca24cf..3da0b01377f34 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -22,7 +22,8 @@ "Guides": [ "routing", "relay-debugging", - "graphql-server-specification" + "graphql-server-specification", + "persisted-queries" ], "Migration Guides": [ "new-in-relay-modern",