├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ ├── __snapshots__ │ └── codegen.test.ts.snap └── codegen.test.ts ├── jest.config.js ├── package.json ├── src ├── apollo.ts └── index.ts ├── tsconfig.build.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | /lib 4 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Esa-Matti Suuronen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generate Persisted Query IDs 2 | 3 | A plugin for graphql-code-generator 4 | 5 | ## Install 6 | 7 | Install graphql-code-generator and this plugin 8 | 9 | npm i -D graphql-code-generator graphql-codegen-persisted-query-ids 10 | 11 | ## Usage 12 | 13 | Create codegen.yml 14 | 15 | ```yaml 16 | schema: http://app.test/graphql 17 | documents: "./src/**/*.js" 18 | generates: 19 | persisted-query-ids/client.json: 20 | - graphql-codegen-persisted-query-ids: 21 | output: client 22 | algorithm: sha256 23 | 24 | persisted-query-ids/server.json: 25 | - graphql-codegen-persisted-query-ids: 26 | output: server 27 | algorithm: sha256 28 | ``` 29 | 30 | Run the generator 31 | 32 | mkdir persisted-query-ids 33 | ./node_modules/.bin/gql-gen --overwrite 34 | 35 | This will generate two json files. The `server.json` is a query id mapping to 36 | the actual queries which should be consumed by the server. 37 | 38 | Example 39 | 40 | ```json 41 | { 42 | "093eb2253f63de7afc7c4637bf19273a09591c2139bc068de320ae78e39755d9": "query Thing { field }" 43 | } 44 | ``` 45 | 46 | The `client.json` file is an operation name mapping to the query id to be 47 | consumed by the GraphQL clients. 48 | 49 | ```json 50 | { 51 | "Thing": "093eb2253f63de7afc7c4637bf19273a09591c2139bc068de320ae78e39755d9" 52 | } 53 | ``` 54 | 55 | ### Integrating with WPGraphQL 56 | 57 | Use the [wp-graphql-lock][] plugin 58 | 59 | cd wp-content/plugins 60 | git clone https://github.com/valu-digital/wp-graphql-lock 61 | 62 | [wp-graphql-lock]: https://github.com/valu-digital/wp-graphql-lock 63 | 64 | In your theme's `functions.php` add 65 | 66 | ```php 67 | add_filter( 'graphql_lock_load_query', function( string $query, string $query_id ) { 68 | $queries = json_decode( file_get_contents( __DIR__ . '/../persisted-query-ids/server.json' ), true ); 69 | return $queries[ $query_id ] ?? null; 70 | }, 10, 2 ); 71 | 72 | ``` 73 | 74 | ### Integrating with Apollo Client 75 | 76 | Add custom `generateHash` to [apollo-link-persisted-queries](https://github.com/apollographql/apollo-link-persisted-queries) 77 | 78 | ```ts 79 | import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; 80 | // import {createPersistedQueryLink } from "apollo-link-persisted-queries"; // For Apollo Client v2 81 | import { usePregeneratedHashes } from "graphql-codegen-persisted-query-ids/lib/apollo"; 82 | 83 | const hashes = require("../persisted-query-ids/client.json"); 84 | 85 | const persistedLink = createPersistedQueryLink({ 86 | useGETForHashedQueries: true, // Optional but allows better caching 87 | generateHash: usePregeneratedHashes(hashes), 88 | }); 89 | 90 | // And pass it to ApolloClient 91 | 92 | const client = new ApolloClient({ 93 | link: persistedLink.concat(createHttpLink({ uri: "/graphql" })), 94 | cache: new InMemoryCache(), 95 | }); 96 | ``` 97 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/codegen.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`can generate query for simple doc query ids match from client to server 1`] = ` 4 | "query Foo { 5 | bar 6 | }" 7 | `; 8 | -------------------------------------------------------------------------------- /__tests__/codegen.test.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode, parse } from "graphql"; 2 | import { 3 | generateQueryIds, 4 | findUsedFragments, 5 | findFragments, 6 | plugin, 7 | PluginConfig, 8 | } from "../src"; 9 | import { Types } from "@graphql-codegen/plugin-helpers"; 10 | 11 | const SCHEMA = {} as any; 12 | 13 | // Nooop gql fn for prettier 14 | function gql(...things: TemplateStringsArray[]) { 15 | return things.join(""); 16 | } 17 | 18 | function runPlugin(docs: DocumentNode[], config: PluginConfig): any { 19 | const documents: Types.DocumentFile[] = docs.map((doc) => ({ 20 | filePath: "", 21 | document: doc, 22 | })); 23 | return JSON.parse(plugin(SCHEMA, documents, config) as string); 24 | } 25 | 26 | describe("can generate query for simple doc", () => { 27 | const doc = parse(gql` 28 | query Foo { 29 | bar 30 | } 31 | `); 32 | 33 | test("client", async () => { 34 | const client: any = runPlugin([doc], { 35 | output: "client", 36 | }); 37 | 38 | expect(client["Foo"]).toBeTruthy(); 39 | }); 40 | 41 | test("query ids match from client to server", () => { 42 | const client = runPlugin([doc], { 43 | output: "client", 44 | }); 45 | 46 | const server = runPlugin([doc], { 47 | output: "server", 48 | }); 49 | 50 | const query = server[client["Foo"]]; 51 | expect(query).toBeTruthy(); 52 | expect(query).toMatchSnapshot(); 53 | }); 54 | 55 | describe("fragments", () => { 56 | const doc1 = parse(gql` 57 | fragment myFragment on Ding { 58 | name 59 | } 60 | 61 | query Foo { 62 | bar 63 | ...myFragment 64 | } 65 | `); 66 | 67 | const doc2 = parse(gql` 68 | query Foo2 { 69 | bar 70 | } 71 | `); 72 | 73 | const multiQueryDoc = parse(gql` 74 | fragment myFragment on Ding { 75 | name 76 | } 77 | 78 | query Foo { 79 | bar 80 | ...myFragment 81 | } 82 | 83 | query Foo2 { 84 | bar 85 | } 86 | `); 87 | 88 | test("is added to queries", () => { 89 | const server = runPlugin([doc1, doc2], { 90 | output: "server", 91 | }); 92 | 93 | const client = runPlugin([doc1, doc2], { 94 | output: "client", 95 | }); 96 | 97 | expect(server[client["Foo"]]).toContain("fragment myFragment"); 98 | }); 99 | 100 | test("multiple docs not using fragments", () => { 101 | const server = runPlugin([doc1, doc2], { 102 | output: "server", 103 | }); 104 | 105 | const client = runPlugin([doc1, doc2], { 106 | output: "client", 107 | }); 108 | 109 | expect(server[client["Foo2"]]).not.toContain("fragment myFragment"); 110 | }); 111 | 112 | test("multi query doc", () => { 113 | const server = runPlugin([multiQueryDoc], { 114 | output: "server", 115 | }); 116 | 117 | const client = runPlugin([multiQueryDoc], { 118 | output: "client", 119 | }); 120 | 121 | expect(server[client["Foo"]]).toContain("fragment myFragment"); 122 | expect(server[client["Foo2"]]).not.toContain("fragment myFragment"); 123 | }); 124 | 125 | test("can use fragment before it's definition", () => { 126 | const doc = parse(gql` 127 | query Foo { 128 | bar 129 | ...myFragment 130 | } 131 | 132 | fragment myFragment on Ding { 133 | name 134 | } 135 | `); 136 | 137 | const server = runPlugin([doc], { 138 | output: "server", 139 | }); 140 | 141 | const client = runPlugin([doc], { 142 | output: "client", 143 | }); 144 | 145 | const query = server[client["Foo"]]; 146 | expect(query).toBeTruthy(); 147 | }); 148 | 149 | test("fragments in fragments work", () => { 150 | const fragInFrag = parse(gql` 151 | fragment nestedFrag on Ding { 152 | fromNested 153 | } 154 | 155 | fragment myFragment on Ding { 156 | name 157 | ...nestedFrag 158 | } 159 | 160 | query Foo { 161 | bar 162 | ...myFragment 163 | } 164 | `); 165 | 166 | const server = runPlugin([fragInFrag], { 167 | output: "server", 168 | }); 169 | 170 | const client = runPlugin([fragInFrag], { 171 | output: "client", 172 | }); 173 | 174 | const query = server[client["Foo"]]; 175 | expect(query).toBeTruthy(); 176 | expect(query).toContain("fragment nestedFrag"); 177 | expect(query).toContain("fragment myFragment"); 178 | }); 179 | }); 180 | 181 | describe("mutation", () => { 182 | const doc = parse(gql` 183 | mutation AddTodo($title: String!) { 184 | createTodo( 185 | input: { 186 | title: $title 187 | clientMutationId: "lala" 188 | completed: false 189 | status: PUBLISH 190 | } 191 | ) { 192 | clientMutationId 193 | todo { 194 | id 195 | title 196 | completed 197 | } 198 | } 199 | } 200 | `); 201 | 202 | test("query ids match from client to server", () => { 203 | const client = runPlugin([doc], { 204 | output: "client", 205 | }); 206 | 207 | const server = runPlugin([doc], { 208 | output: "server", 209 | }); 210 | 211 | const query = server[client["AddTodo"]]; 212 | expect(query).toBeTruthy(); 213 | }); 214 | }); 215 | 216 | test("can find nested fragment user", () => { 217 | const doc = parse(gql` 218 | fragment TodoParts on Todo { 219 | title 220 | } 221 | 222 | query DualTodoList($cursorTodos: String!, $cursorDones: String!) { 223 | todos( 224 | first: 3 225 | after: $cursorTodos 226 | where: { completed: false } 227 | ) { 228 | ...TodoParts 229 | } 230 | dones: todos( 231 | first: 3 232 | after: $cursorDones 233 | where: { completed: true } 234 | ) { 235 | ...TodoParts 236 | } 237 | } 238 | `); 239 | 240 | const operation = doc.definitions.find( 241 | (def) => def.kind === "OperationDefinition", 242 | ); 243 | 244 | if (!operation || operation.kind !== "OperationDefinition") { 245 | throw new Error("cannot find operation"); 246 | } 247 | 248 | const knownFragments = findFragments([doc]); 249 | 250 | const fragmentNames = Array.from( 251 | findUsedFragments(operation, knownFragments).values(), 252 | ).map((frag) => frag.name.value); 253 | 254 | expect(fragmentNames).toEqual(["TodoParts"]); 255 | }); 256 | }); 257 | 258 | describe("can extract variable info", () => { 259 | const doc1 = parse(gql` 260 | query Foo { 261 | bar 262 | } 263 | `); 264 | 265 | const doc2 = parse(gql` 266 | query Foo($foo: String) { 267 | bar 268 | } 269 | `); 270 | 271 | test("does not use variables", async () => { 272 | const client = generateQueryIds([doc1], { output: "client" }); 273 | 274 | expect(client["Foo"]).toMatchObject({ 275 | hash: expect.stringMatching(/.+/), 276 | query: expect.stringContaining("query Foo"), 277 | usesVariables: false, 278 | }); 279 | }); 280 | 281 | test("does use variables", async () => { 282 | const client = generateQueryIds([doc2], { output: "client" }); 283 | 284 | expect(client["Foo"]).toMatchObject({ 285 | hash: expect.stringMatching(/.+/), 286 | query: expect.stringContaining("query Foo"), 287 | usesVariables: true, 288 | }); 289 | }); 290 | }); 291 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ["/node_modules/", "build"], 3 | transform: { 4 | "^.+\\.(ts|tsx)$": "ts-jest", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-codegen-persisted-query-ids", 3 | "version": "0.2.0", 4 | "description": "Generate persisted query ids", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "url": "https://github.com/epeli/graphql-codegen-persisted-query-ids" 8 | }, 9 | "scripts": { 10 | "test": "jest", 11 | "build": "tsc --project tsconfig.build.json && rm -rf lib && mv build/src lib && rm -rf build", 12 | "clean": "rm -rf lib build", 13 | "prepublishOnly": "npm run test && npm run build", 14 | "dev": "npm run build && graphql-codegen --config ./dev/codegen.yml" 15 | }, 16 | "devDependencies": { 17 | "@types/jest": "^25.2.1", 18 | "@types/node": "^13.11.0", 19 | "jest": "^29.6.1", 20 | "prettier": "^2.0.4", 21 | "ts-jest": "^29.1.1", 22 | "typescript": "^5.1.6" 23 | }, 24 | "dependencies": { 25 | "@graphql-codegen/plugin-helpers": "^5.0.0", 26 | "@graphql-tools/apollo-engine-loader": "^8.0.0", 27 | "graphql": "^16.7.1" 28 | }, 29 | "files": [ 30 | "lib" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/apollo.ts: -------------------------------------------------------------------------------- 1 | export function usePregeneratedHashes(hashes: { 2 | [operationsName: string]: string | undefined; 3 | }) { 4 | return (doc: import("graphql").DocumentNode) => { 5 | const operationDefinition = doc.definitions.find( 6 | def => def.kind === "OperationDefinition", 7 | ); 8 | 9 | if ( 10 | !operationDefinition || 11 | operationDefinition.kind !== "OperationDefinition" 12 | ) { 13 | console.error("Cannot find OperationDefinition from", doc); 14 | throw new Error("Operation missing from graphql query"); 15 | } 16 | 17 | if (!operationDefinition.name) { 18 | throw new Error("name missing from operation definition"); 19 | } 20 | 21 | const hash = hashes[operationDefinition.name.value]; 22 | 23 | if (!hash) { 24 | throw new Error( 25 | "Cannot find pregerated has for " + 26 | operationDefinition.name.value, 27 | ); 28 | } 29 | 30 | return hash; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { 3 | print, 4 | DocumentNode, 5 | OperationDefinitionNode, 6 | visit, 7 | FieldNode, 8 | FragmentDefinitionNode, 9 | Location, 10 | Kind, 11 | } from "graphql"; 12 | 13 | import { PluginFunction } from "@graphql-codegen/plugin-helpers"; 14 | 15 | type Definition = FragmentDefinitionNode | OperationDefinitionNode; 16 | 17 | function createHash(s: string, config: PluginConfig) { 18 | return crypto 19 | .createHash(config.algorithm || "sha256") 20 | .update(s, "utf8") 21 | .digest() 22 | .toString("hex"); 23 | } 24 | 25 | function printDefinitions(definitions: (Definition | DocumentNode)[]) { 26 | return definitions.map(print).join("\n"); 27 | } 28 | 29 | const TYPENAME_FIELD: FieldNode = { 30 | kind: Kind.FIELD, 31 | name: { 32 | kind: Kind.NAME, 33 | value: "__typename", 34 | }, 35 | }; 36 | 37 | // From apollo-client https://github.com/apollographql/apollo-client/blob/3a9dfe268979618180823eef93e96ab87468449c/packages/apollo-utilities/src/transform.ts 38 | function addTypenameToDocument(doc: DocumentNode): DocumentNode { 39 | return visit(doc, { 40 | SelectionSet: { 41 | enter(node, _key, parent) { 42 | // Don't add __typename to OperationDefinitions. 43 | if ( 44 | parent && 45 | (parent as OperationDefinitionNode).kind === 46 | "OperationDefinition" 47 | ) { 48 | return; 49 | } 50 | 51 | // No changes if no selections. 52 | const { selections } = node; 53 | if (!selections) { 54 | return; 55 | } 56 | 57 | // If selections already have a __typename, or are part of an 58 | // introspection query, do nothing. 59 | const skip = selections.some((selection) => { 60 | return ( 61 | selection.kind === "Field" && 62 | ((selection as FieldNode).name.value === "__typename" || 63 | (selection as FieldNode).name.value.lastIndexOf( 64 | "__", 65 | 0, 66 | ) === 0) 67 | ); 68 | }); 69 | if (skip) { 70 | return; 71 | } 72 | 73 | // Create and return a new SelectionSet with a __typename Field. 74 | return { 75 | ...node, 76 | selections: [...selections, TYPENAME_FIELD], 77 | }; 78 | }, 79 | }, 80 | }); 81 | } 82 | 83 | export interface PluginConfig { 84 | output: "server" | "client" | undefined; 85 | algorithm?: string; 86 | } 87 | 88 | export function findUsedFragments( 89 | operation: OperationDefinitionNode | FragmentDefinitionNode, 90 | knownFragments: ReadonlyMap, 91 | _usedFragments?: Map, 92 | ) { 93 | const usedFragments = _usedFragments 94 | ? _usedFragments 95 | : new Map(); 96 | 97 | visit(operation, { 98 | FragmentSpread: { 99 | enter(node) { 100 | const frag = knownFragments.get(node.name.value); 101 | if (frag) { 102 | usedFragments.set(node.name.value, frag); 103 | findUsedFragments(frag, knownFragments, usedFragments); 104 | } else { 105 | throw new Error("Unknown fragment: " + node.name.value); 106 | } 107 | }, 108 | }, 109 | }); 110 | 111 | return usedFragments; 112 | } 113 | 114 | export function findFragments(docs: (DocumentNode | FragmentDefinitionNode)[]) { 115 | const fragments = new Map(); 116 | 117 | for (const doc of docs) { 118 | visit(doc, { 119 | FragmentDefinition: { 120 | enter(node) { 121 | fragments.set(node.name.value, node); 122 | }, 123 | }, 124 | }); 125 | } 126 | 127 | return fragments; 128 | } 129 | 130 | export function generateQueryIds(docs: DocumentNode[], config: PluginConfig) { 131 | docs = docs.map(addTypenameToDocument); 132 | 133 | const out: { 134 | [queryName: string]: { 135 | hash: string; 136 | query: string; 137 | usesVariables: boolean; 138 | loc?: Location; 139 | }; 140 | } = {}; 141 | 142 | const knownFragments = findFragments(docs); 143 | 144 | for (const doc of docs) { 145 | visit(doc, { 146 | OperationDefinition: { 147 | enter(def) { 148 | if (!def.name) { 149 | throw new Error("OperationDefinition missing name"); 150 | } 151 | 152 | const usedFragments = findUsedFragments( 153 | def, 154 | knownFragments, 155 | ); 156 | 157 | const query = printDefinitions([ 158 | ...Array.from(usedFragments.values()), 159 | def, 160 | ]); 161 | 162 | const hash = createHash(query, config); 163 | 164 | const usesVariables = Boolean( 165 | def.variableDefinitions && 166 | def.variableDefinitions.length > 0, 167 | ); 168 | 169 | out[def.name.value] = { 170 | hash, 171 | query, 172 | usesVariables, 173 | loc: doc.loc, 174 | }; 175 | }, 176 | }, 177 | }); 178 | } 179 | 180 | return out; 181 | } 182 | 183 | export const format = { 184 | server(queries: ReturnType) { 185 | const out: Record = {}; 186 | for (const queryName of Object.keys(queries)) { 187 | out[queries[queryName].hash] = queries[queryName].query; 188 | } 189 | 190 | return out; 191 | }, 192 | 193 | client(queries: ReturnType) { 194 | const out: Record = {}; 195 | for (const queryName of Object.keys(queries)) { 196 | out[queryName] = queries[queryName].hash; 197 | } 198 | return out; 199 | }, 200 | }; 201 | 202 | export const plugin: PluginFunction = ( 203 | _schema, 204 | documents, 205 | config, 206 | ) => { 207 | const queries = generateQueryIds( 208 | documents.map((doc) => { 209 | // graphql-code-generator moved from .content to .document at some point. 210 | // Try to work with both. Must use any since the tests can only have 211 | // one version of the typings 212 | const anyDoc = doc as any; 213 | const docNode: DocumentNode = anyDoc.content || anyDoc.document; 214 | return docNode; 215 | }), 216 | config, 217 | ); 218 | 219 | let out: Record = {}; 220 | 221 | if (config.output === "client") { 222 | out = format.client(queries); 223 | } else if (config.output === "server") { 224 | out = format.server(queries); 225 | } else { 226 | throw new Error( 227 | "graphql-codegen-persisted-query-id must configure output to 'server' or 'client'", 228 | ); 229 | } 230 | 231 | return JSON.stringify(out, null, " "); 232 | }; 233 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["__dtslint__", "build"], 4 | "compilerOptions": { 5 | "sourceMap": true, 6 | "noEmit": false, 7 | "outDir": "./build", 8 | "declaration": true, 9 | "declarationDir": "./build" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "noEmit": true, 6 | "jsx": "react", 7 | "lib": ["esnext", "dom"], 8 | "moduleResolution": "node", 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "esModuleInterop": true 12 | }, 13 | "exclude": ["build"] 14 | } 15 | --------------------------------------------------------------------------------