├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── core.test.ts ├── core.ts ├── errors.ts ├── express.test.ts ├── express.ts ├── index.ts └── memory-storage.ts ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["taller", "prettier"], 3 | "parser": "@typescript-eslint/parser", 4 | "rules": { 5 | "no-unused-expressions": "off", 6 | "no-unused-vars": "off" 7 | }, 8 | "globals": { 9 | "jest": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | .eslintcache 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | .babelrc 4 | .eslintrc 5 | .gitignore 6 | .travis.yml 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "10" 5 | - "9" 6 | - "7" 7 | - "6" 8 | after_script: 9 | - "npm run codecov" 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Lucas Constantino Silva 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 | # GraphQL APQ 2 | 3 | [Automatic persisted queries](https://www.apollographql.com/docs/apollo-server/features/apq) made easy. 4 | 5 | [![build status](https://img.shields.io/travis/lucasconstantino/graphql-apq/master.svg?style=flat-square)](https://travis-ci.org/lucasconstantino/graphql-apq) 6 | [![coverage](https://img.shields.io/codecov/c/github/lucasconstantino/graphql-apq.svg?style=flat-square)](https://codecov.io/github/lucasconstantino/graphql-apq) 7 | [![npm version](https://img.shields.io/npm/v/graphql-apq.svg?style=flat-square)](https://www.npmjs.com/package/graphql-apq) 8 | [![sponsored by Taller](https://raw.githubusercontent.com/TallerWebSolutions/tallerwebsolutions.github.io/master/sponsored-by-taller.png)](https://taller.net.br/en/) 9 | 10 | --- 11 | 12 | This library consists of a server-side implementation of the [persisted queries protocol](https://github.com/apollographql/apollo-link-persisted-queries#protocol) as presented by the [Apollo Engine](https://www.apollographql.com/engine) team. 13 | 14 | Apollo Engine is a paid GraphQL gateway with many wonderful tools, and this project brings to the open-source world one of those tools. 15 | 16 | Persisted queries was [first brought up](https://dev-blog.apollodata.com/persisted-graphql-queries-with-apollo-client-119fd7e6bba5) by the Apollo team, but relied mostly on complicated building process to achieve the full benefit proposed. Automatic persisted queries is a concept built on top of that idea, which allows for persisted queries to be registered in run-time. 17 | 18 | ### How it works 19 | 20 | 1. When the client makes a query, it will optimistically send a short (64-byte) cryptographic hash instead of the full query text. 21 | 1. If the backend recognizes the hash, it will retrieve the full text of the query and execute it. 22 | 1. If the backend doesn't recogize the hash, it will ask the client to send the hash and the query text to it can store them mapped together for future lookups. During this request, the backend will also fulfill the data request. 23 | 24 | This library is a server implementation for use with any GraphQL server. 25 | 26 | You can use any client-side implementation, as long as it follows the same protocol, but we strongly recommend using the [apollo-link-persisted-queries](https://github.com/apollographql/apollo-link-persisted-queries) project. 27 | 28 | ## Installation 29 | 30 | ``` 31 | npm install graphql-apq --save 32 | ``` 33 | 34 | ## Usage 35 | 36 | This project currently provides a core system for handling persisted queries and an `express` middleware to integrate it to a GraphQL server of choice. It will eventually also provide an `extension` to the Apollo Server project, as soon as [extensions are implemented](https://github.com/apollographql/apollo-server/pull/1105) in that project. 37 | 38 | ### Middleware 39 | 40 | ```js 41 | import persistedQueries from 'graphql-apq/lib/express' 42 | import express from 'express' 43 | import bodyParser from 'body-parser' 44 | import { graphqlExpress } from 'apollo-server-express' 45 | 46 | const schema = // ... define or import your schema here! 47 | const PORT = 3000; 48 | 49 | const app = express(); 50 | 51 | app 52 | .use('/graphql', bodyParser.json(), persistedQueries(), graphqlExpress({ schema })) 53 | .listen(PORT) 54 | ``` 55 | 56 | #### Options 57 | 58 | You can alter some of APQ's default behavior by providing an object of 59 | options to the middleware initialization as follows: 60 | 61 | ##### `cache` 62 | 63 | A cache object implementing at least the following interface: 64 | 65 | ```ts 66 | interface CacheInterface { 67 | get: (key: string) => string | null | Promise 68 | set: (key: string, value: string) => void | Promise 69 | has: (key: string) => boolean | Promise 70 | } 71 | ``` 72 | 73 | Defaults to an instance of 74 | [`memory-cache`](https://github.com/ptarjan/node-cache). Can be modified to 75 | provide a more specialized caching system, such as [`node-redis`](https://github.com/NodeRedis/node-redis). 76 | 77 | ##### `resolveHash` 78 | 79 | A reducer from an operation to the hash to use. Defaults to retrieving the 80 | hash from `operation.extensions.persistedQuery.sha256Hash`. 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-apq", 3 | "version": "1.0.0", 4 | "description": "Automatic persisted queries (APQ) for any GraphQL server.", 5 | "keywords": [ 6 | "graphql", 7 | "persisted queries", 8 | "apq", 9 | "server", 10 | "queries" 11 | ], 12 | "homepage": "https://github.com/lucasconstantino/graphql-apq#readme", 13 | "bugs": { 14 | "url": "https://github.com/lucasconstantino/graphql-apq/issues" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/lucasconstantino/graphql-apq.git" 19 | }, 20 | "license": "MIT", 21 | "author": "Lucas Constantino Silva ", 22 | "main": "lib/index.js", 23 | "scripts": { 24 | "codecov": "yarn test && codecov", 25 | "compile": "babel src --extensions .ts -d lib", 26 | "lint": "eslint src --ext .ts", 27 | "prepublish": "yarn qa && yarn compile", 28 | "qa": "yarn lint && yarn type-check && npm test", 29 | "test": "jest", 30 | "test:watch": "yarn test --watch --collectCoverage=no", 31 | "type-check": "tsc" 32 | }, 33 | "husky": { 34 | "hooks": { 35 | "pre-commit": "lint-staged" 36 | } 37 | }, 38 | "lint-staged": { 39 | "*.{js,json,ts,css,md}": "prettier --write", 40 | "*.{js,ts}": "eslint --cache --fix" 41 | }, 42 | "jest": { 43 | "collectCoverage": true, 44 | "collectCoverageFrom": [ 45 | "src/**/*.js" 46 | ] 47 | }, 48 | "dependencies": { 49 | "memory-cache": "^0.2.0" 50 | }, 51 | "devDependencies": { 52 | "@babel/cli": "^7.8.4", 53 | "@babel/preset-env": "^7.9.5", 54 | "@babel/preset-typescript": "^7.9.0", 55 | "@types/jest": "^25.2.1", 56 | "@types/memory-cache": "^0.2.1", 57 | "@types/supertest": "^2.0.8", 58 | "@typescript-eslint/parser": "^2.30.0", 59 | "apollo-server-express": "^2.12.0", 60 | "codecov": "^3.6.5", 61 | "eslint": "^6.8.0", 62 | "eslint-config-prettier": "^6.11.0", 63 | "eslint-config-taller": "^2.0.0", 64 | "eslint-plugin-import": "^2.20.2", 65 | "eslint-plugin-node": "^11.1.0", 66 | "eslint-plugin-standard": "^4.0.1", 67 | "express": "^4.17.1", 68 | "graphql": "^15.0.0", 69 | "graphql-tools": "^5.0.0", 70 | "husky": ">=4", 71 | "jest": "^25.5.0", 72 | "lint-staged": ">=10", 73 | "prettier": "^2.0.5", 74 | "ramda": "^0.27.0", 75 | "supertest": "^4.0.2", 76 | "typescript": "^3.8.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/core.test.ts: -------------------------------------------------------------------------------- 1 | import APQ, { CacheInterface } from './' 2 | 3 | describe('core', () => { 4 | let cache: jest.Mocked 5 | let apq: APQ 6 | 7 | const getCache = () => ({ 8 | get: jest.fn(), 9 | set: jest.fn(), 10 | has: jest.fn(), 11 | }) 12 | 13 | beforeEach(() => { 14 | cache = getCache() 15 | apq = new APQ({ cache }) 16 | }) 17 | 18 | it('should create an APQ instance', () => { 19 | expect(new APQ()).toBeInstanceOf(APQ) 20 | }) 21 | 22 | describe('cache', () => { 23 | it('should create a default cache object', () => { 24 | // @ts-ignore 25 | const cache = new APQ().cache 26 | expect(cache).toHaveProperty('get', expect.any(Function)) 27 | expect(cache).toHaveProperty('set', expect.any(Function)) 28 | expect(cache).toHaveProperty('has', expect.any(Function)) 29 | }) 30 | 31 | it('should be possible to provide a custom cache object', () => { 32 | const cache = getCache() 33 | // @ts-ignore 34 | expect(new APQ({ cache }).cache).toBe(cache) 35 | }) 36 | 37 | it('should throw for invalid custom cache', () => { 38 | // @ts-ignore 39 | expect(() => new APQ({ cache: '' })).toThrow('Invalid cache') 40 | // @ts-ignore 41 | expect(() => new APQ({ cache: {} })).toThrow('Invalid cache') 42 | }) 43 | 44 | it('should throw for invalid resolveHash', () => { 45 | // @ts-ignore 46 | expect(() => new APQ({ resolveHash: '' })).toThrow('Invalid resolveHash') 47 | // @ts-ignore 48 | expect(() => new APQ({ resolveHash: {} })).toThrow('Invalid resolveHash') 49 | }) 50 | }) 51 | 52 | describe('formatError', () => { 53 | it('should return a persisted query formatter error', () => { 54 | expect(apq.formatError(new Error('message'))).toHaveProperty( 55 | 'errors.0.message', 56 | 'message' 57 | ) 58 | }) 59 | 60 | it('should be possible to provide a custom persisted query error formatter', () => { 61 | const error = new Error('message') 62 | const formatError = () => 'custom' 63 | expect(new APQ({ formatError }).formatError(error)).toBe('custom') 64 | }) 65 | }) 66 | 67 | describe('processOperation', () => { 68 | describe('errors', () => { 69 | it.each([ 70 | [1, 'Invalid GraphQL operation provided'], 71 | ['', 'Invalid GraphQL operation provided'], 72 | [null, 'No GraphQL operation provided'], 73 | [undefined, 'No GraphQL operation provided'], 74 | ])('should throw for invalid GraphQL operations', (op, expected) => { 75 | // @ts-ignore 76 | return expect(() => apq.processOperation(op)).rejects.toThrow(expected) 77 | }) 78 | 79 | it('should throw for missing hash', async () => { 80 | await expect(() => apq.processOperation({})).rejects.toThrow( 81 | 'PersistedQueryHashMissing' 82 | ) 83 | 84 | await expect(() => 85 | apq.processOperation({ query: 'graphql query' }) 86 | ).rejects.toThrow('PersistedQueryHashMissing') 87 | }) 88 | 89 | it('should throw for query not found', async () => { 90 | await expect(() => apq.processOperation({})).rejects.toThrow( 91 | 'PersistedQueryHashMissing' 92 | ) 93 | 94 | await expect(() => 95 | apq.processOperation({ query: 'graphql query' }) 96 | ).rejects.toThrow('PersistedQueryHashMissing') 97 | }) 98 | 99 | it('should throw when provided hash is not cached', async () => { 100 | const sha256Hash = 'some hash' 101 | const operation = { extensions: { persistedQuery: { sha256Hash } } } 102 | 103 | await expect(() => apq.processOperation(operation)).rejects.toThrow( 104 | 'PersistedQueryNotFound' 105 | ) 106 | }) 107 | }) 108 | 109 | it('should return unaltered operation when there is a query and it is already cached', async () => { 110 | const query = 'some query' 111 | const sha256Hash = 'some hash' 112 | 113 | const operation = { 114 | query, 115 | extensions: { persistedQuery: { sha256Hash } }, 116 | } 117 | 118 | // @ts-ignore 119 | await apq.cache.set(sha256Hash, query) 120 | 121 | expect(await apq.processOperation(operation)).toBe(operation) 122 | }) 123 | 124 | it('should return unaltered operation when no hash is available and not requiring hashes', async () => { 125 | const apq = new APQ({ requireHash: false }) 126 | const operation = {} 127 | expect(await apq.processOperation(operation)).toEqual(operation) 128 | }) 129 | 130 | it('should add the query to the operation when already cached', async () => { 131 | const query = 'some query' 132 | const sha256Hash = 'some hash' 133 | const operation = { extensions: { persistedQuery: { sha256Hash } } } 134 | 135 | cache.has.mockReturnValueOnce(true) 136 | cache.get.mockReturnValueOnce(query) 137 | 138 | const result = await apq.processOperation(operation) 139 | 140 | expect(result).toHaveProperty('query', query) 141 | }) 142 | 143 | it('should add the query to the cache when both query and hash are available', async () => { 144 | const query = 'some query' 145 | const sha256Hash = 'some hash' 146 | 147 | const operation = { 148 | query, 149 | extensions: { 150 | persistedQuery: { 151 | sha256Hash, 152 | }, 153 | }, 154 | } 155 | 156 | expect(await apq.processOperation(operation)).toBe(operation) 157 | 158 | // @ts-ignore 159 | expect(apq.cache.set).toHaveBeenCalledTimes(1) 160 | // @ts-ignore 161 | expect(apq.cache.set).toHaveBeenCalledWith(sha256Hash, query) 162 | }) 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import { getStorage } from './memory-storage' 2 | import { errors, PersistedQueryError } from './errors' 3 | 4 | export interface CacheInterface { 5 | get: (key: string) => string | null | Promise 6 | set: (key: string, value: string) => void | Promise 7 | has: (key: string) => boolean | Promise 8 | } 9 | 10 | export interface Operation { 11 | query?: string 12 | extensions?: { persistedQuery?: { sha256Hash?: string } } 13 | } 14 | 15 | export type HashResolver = ( 16 | operation: Operation 17 | ) => string | null | Promise 18 | 19 | export type ErrorFormatter = (error: PersistedQueryError) => any 20 | 21 | export interface APQConfig { 22 | cache?: CacheInterface 23 | formatError?: ErrorFormatter 24 | requireHash?: boolean 25 | resolveHash?: HashResolver 26 | } 27 | 28 | const defaults = { 29 | cache: getStorage(), 30 | requireHash: true, 31 | formatError: (error: PersistedQueryError) => ({ 32 | errors: [{ message: error.message }], 33 | }), 34 | resolveHash: (operation: Operation) => 35 | operation?.extensions?.persistedQuery?.sha256Hash || null, 36 | } 37 | 38 | class APQ { 39 | private cache: CacheInterface 40 | private requireHash: boolean 41 | private resolveHash: HashResolver 42 | public formatError: ErrorFormatter 43 | 44 | constructor(_config?: APQConfig) { 45 | const config = { ...defaults, ..._config } 46 | 47 | this.cache = config.cache 48 | this.formatError = config.formatError 49 | this.requireHash = config.requireHash 50 | this.resolveHash = config.resolveHash 51 | 52 | this.validateConfig() 53 | } 54 | 55 | private validateConfig() { 56 | const cacheMethods = ['get', 'set', 'has'] as const 57 | 58 | if (!this.cache || !cacheMethods.every((key) => this.cache[key])) { 59 | throw new Error('Invalid cache provided') 60 | } 61 | 62 | if (typeof this.resolveHash !== 'function') { 63 | throw new Error('Invalid resolveHash provided') 64 | } 65 | } 66 | 67 | /** 68 | * Processes an operation and ensure query is set, when possible. 69 | */ 70 | async processOperation(operation: Operation) { 71 | if (typeof operation !== 'object' && typeof operation !== 'undefined') { 72 | throw new Error('Invalid GraphQL operation provided') 73 | } 74 | 75 | if (!operation) { 76 | throw new Error('No GraphQL operation provided') 77 | } 78 | 79 | const { query } = operation 80 | const hash = await this.resolveHash(operation) 81 | 82 | if (!hash) { 83 | // Advise user on missing required hash. 84 | if (this.requireHash) { 85 | throw new errors.HASH_MISSING() 86 | } 87 | 88 | // Proceed with unmodified operation in case no hash is present. 89 | return operation 90 | } 91 | 92 | const isCached = hash && (await this.cache.has(hash)) 93 | 94 | // Proceed with unmodified operation in case query is present and already cached. 95 | if (query && isCached) { 96 | return operation 97 | } 98 | 99 | // Append query to the operation in case we have it cached. 100 | if (!query && isCached) { 101 | return { ...operation, query: this.cache.get(hash) } 102 | } 103 | 104 | // Add the query to the cache in case we don't have it yet. 105 | if (query && !isCached) { 106 | await this.cache.set(hash, query) 107 | } 108 | 109 | // Proceed with original operation if we had both query and hash, but was 110 | // already cached. 111 | if (query) { 112 | return operation 113 | } 114 | 115 | // Fail with no persisted query found in case no query could be resolved. 116 | throw new errors.NOT_FOUND() 117 | } 118 | } 119 | 120 | export { APQ } 121 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | class PersistedQueryError extends Error {} 2 | 3 | /** 4 | * Error for when no query could be resolve. 5 | */ 6 | class PersistedQueryNotFound extends PersistedQueryError { 7 | public message = 'PersistedQueryNotFound' 8 | } 9 | 10 | /** 11 | * Error for when no hash is found in the operation. 12 | */ 13 | class PersistedQueryHashMissing extends PersistedQueryError { 14 | public message = 'PersistedQueryHashMissing' 15 | } 16 | 17 | const errors = { 18 | NOT_FOUND: PersistedQueryNotFound, 19 | HASH_MISSING: PersistedQueryHashMissing, 20 | } 21 | 22 | export { PersistedQueryError, errors } 23 | -------------------------------------------------------------------------------- /src/express.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import bodyParser from 'body-parser' 3 | import express, { 4 | RequestHandler, 5 | Response, 6 | Request, 7 | NextFunction, 8 | } from 'express' 9 | 10 | import { APQConfig } from './' 11 | import persistedQuery from './express' 12 | 13 | const operation = (query?: string | null, sha256Hash?: string | null) => ({ 14 | ...(sha256Hash ? { extensions: { persistedQuery: { sha256Hash } } } : {}), 15 | ...(query ? { query } : {}), 16 | }) 17 | 18 | describe('express middleware', () => { 19 | describe('unit', () => { 20 | const args = (body?: object) => 21 | [ 22 | { body } as Request, 23 | ({ setHeader: jest.fn(), send: jest.fn() } as unknown) as Response, 24 | jest.fn() as NextFunction, 25 | ] as const 26 | 27 | const expectErrorResponse = (res: Response, message: string) => { 28 | expect(res.setHeader).toHaveBeenCalledTimes(1) 29 | expect(res.setHeader).toHaveBeenCalledWith( 30 | 'Content-Type', 31 | 'application/json' 32 | ) 33 | expect(res.send).toHaveBeenCalledTimes(1) 34 | expect(res.send).toHaveProperty( 35 | 'mock.calls.0.0', 36 | JSON.stringify({ errors: [{ message }] }) 37 | ) 38 | } 39 | 40 | describe('errors', () => { 41 | it('should respond error when missing query hash', async () => { 42 | const [req, res, next] = args({}) 43 | await persistedQuery()(req, res, next) 44 | expectErrorResponse(res, 'PersistedQueryHashMissing') 45 | }) 46 | 47 | it('should respond error when no query found', async () => { 48 | const body = operation(null, 'some hash') 49 | const [req, res, next] = args(body) 50 | await persistedQuery()(req, res, next) 51 | 52 | expect(req.body).toBe(body) 53 | expectErrorResponse(res, 'PersistedQueryNotFound') 54 | }) 55 | }) 56 | 57 | it('should persist query when both hash an query are provided', async () => { 58 | const sha256Hash = 'some hash' 59 | const query = 'some query' 60 | 61 | const body = operation(query, sha256Hash) 62 | 63 | const cache = { 64 | get: jest.fn(), 65 | set: jest.fn(), 66 | has: () => false, 67 | } 68 | 69 | const [req, res, next] = args(body) 70 | const middleware = persistedQuery({ cache }) 71 | 72 | await middleware(req, res, next) 73 | 74 | expect(cache.set).toHaveBeenCalledTimes(1) 75 | expect(cache.set).toHaveBeenCalledWith(sha256Hash, query) 76 | 77 | expect(next).toHaveBeenCalledTimes(1) 78 | expect(req.body).toBe(body) 79 | }) 80 | 81 | it('should fulfil operation with previously persisted query', async () => { 82 | const sha256Hash = 'some hash' 83 | const query = 'some query' 84 | 85 | const cache = { 86 | set: jest.fn(), 87 | get: jest.fn(() => query), 88 | has: () => true, 89 | } 90 | 91 | const body = operation(null, sha256Hash) 92 | 93 | const [req, res, next] = args(body) 94 | const middleware = persistedQuery({ cache }) 95 | 96 | expect(req.body).not.toHaveProperty('query', query) 97 | await middleware(req, res, next) 98 | 99 | expect(cache.get).toHaveBeenCalledTimes(1) 100 | expect(cache.get).toHaveBeenCalledWith(sha256Hash) 101 | 102 | // fulfilled: 103 | expect(req.body).toHaveProperty('query', query) 104 | }) 105 | }) 106 | 107 | describe('integration', () => { 108 | let after: RequestHandler 109 | 110 | const getApp = (config?: APQConfig) => 111 | express() 112 | .use(bodyParser.json()) 113 | .use(persistedQuery(config)) 114 | .use((after = jest.fn((_req, res) => res.send('ok')))) 115 | 116 | describe('errors', () => { 117 | it('should respond error when missing query hash', () => { 118 | return request(getApp()) 119 | .post('/') 120 | .send() 121 | .expect(200, { errors: [{ message: 'PersistedQueryHashMissing' }] }) 122 | }) 123 | 124 | it('should respond error when no query found', async () => { 125 | return request(getApp()) 126 | .post('/') 127 | .send(operation(null, 'some hash')) 128 | .expect(200, { errors: [{ message: 'PersistedQueryNotFound' }] }) 129 | }) 130 | }) 131 | 132 | it('should persist query when both hash an query are provided', async () => { 133 | const sha256Hash = 'some hash' 134 | const query = 'some query' 135 | 136 | const cache = { 137 | get: jest.fn(), 138 | set: jest.fn(), 139 | has: () => false, 140 | } 141 | 142 | const body = operation(query, sha256Hash) 143 | 144 | await request(getApp({ cache })).post('/').send(body).expect(200, 'ok') 145 | 146 | expect(cache.set).toHaveBeenCalledTimes(1) 147 | expect(cache.set).toHaveBeenCalledWith(sha256Hash, query) 148 | 149 | expect(after).toHaveBeenCalledTimes(1) 150 | expect(after).toHaveProperty('mock.calls.0.0.body', body) 151 | }) 152 | 153 | it('should fulfil operation with previously persisted query', async () => { 154 | const sha256Hash = 'some hash' 155 | const query = 'some query' 156 | 157 | const cache = { 158 | set: jest.fn(), 159 | get: jest.fn(() => query), 160 | has: () => true, 161 | } 162 | 163 | const body = operation(null, sha256Hash) 164 | 165 | await request(getApp({ cache })).post('/').send(body).expect(200, 'ok') 166 | 167 | expect(cache.get).toHaveBeenCalledTimes(1) 168 | expect(cache.get).toHaveBeenCalledWith(sha256Hash) 169 | 170 | // fulfilled: 171 | expect(after).toHaveProperty('mock.calls.0.0.body.query', query) 172 | }) 173 | }) 174 | }) 175 | -------------------------------------------------------------------------------- /src/express.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | import { APQ, APQConfig } from './core' 3 | import { PersistedQueryError } from './errors' 4 | 5 | export default (options?: APQConfig): RequestHandler => { 6 | const apq = new APQ(options) 7 | 8 | return async (req, res, next) => { 9 | try { 10 | req.body = await apq.processOperation(req.body) 11 | next() 12 | } catch (error) { 13 | // Respond known errors gracefully 14 | if (error instanceof PersistedQueryError) { 15 | res.setHeader('Content-Type', 'application/json') 16 | res.send(JSON.stringify(apq.formatError(error))) 17 | return 18 | } 19 | 20 | next(error) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { APQ } from './core' 2 | export * from './core' 3 | export default APQ 4 | -------------------------------------------------------------------------------- /src/memory-storage.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from 'memory-cache' 2 | 3 | export interface CacheInterface { 4 | get: (key: string) => string | Promise 5 | set: (key: string, value: string) => void | Promise 6 | has: (key: string) => boolean | Promise 7 | } 8 | 9 | const getStorage = () => { 10 | const cache = new Cache() 11 | 12 | return { 13 | get: (key: string) => cache.get(key), 14 | set: (key: string, value: string) => void cache.put(key, value), 15 | has: (key: string) => cache.keys().includes(key), 16 | } 17 | } 18 | 19 | export { getStorage } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "es6", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true, 10 | "noUnusedLocals": true, 11 | "pretty": true, 12 | "esModuleInterop": true, 13 | "lib": ["esnext"] 14 | } 15 | } 16 | --------------------------------------------------------------------------------