├── .editorconfig ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── check-linked-issues.yml │ ├── ci.yml │ ├── notify-release.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .taprc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples └── basic.js ├── index.js ├── lib ├── queries.js └── util.js ├── package.json └── test ├── index.test.js └── util.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "plugin:prettier/recommended"], 3 | "env": { 4 | "commonjs": true 5 | }, 6 | "parserOptions": { 7 | "sourceType": "script" 8 | }, 9 | "rules": { 10 | "strict": "error", 11 | "import/order": [ 12 | "error", 13 | { 14 | "newlines-between": "always" 15 | } 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /.github/workflows/check-linked-issues.yml: -------------------------------------------------------------------------------- 1 | name: Check Linked Issues 2 | 'on': 3 | pull_request: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | - synchronize 9 | jobs: 10 | check_pull_requests: 11 | runs-on: ubuntu-latest 12 | name: Check linked issues 13 | permissions: 14 | issues: read 15 | pull-requests: write 16 | steps: 17 | - uses: nearform-actions/github-action-check-linked-issues@v1 18 | with: 19 | github-token: ${{ secrets.GITHUB_TOKEN }} 20 | exclude-branches: release/**, dependabot/** 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: 13 | - 18 14 | - 20 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Use Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Restore cached dependencies 23 | uses: actions/cache@v4 24 | with: 25 | path: node_modules 26 | key: node-modules-${{ hashFiles('package.json') }} 27 | - name: Install dependencies 28 | run: npm install 29 | - name: Lint 30 | run: npm run lint 31 | - name: Run Tests 32 | run: npm run test:ci 33 | automerge: 34 | needs: build 35 | runs-on: ubuntu-latest 36 | permissions: 37 | pull-requests: write 38 | contents: write 39 | steps: 40 | - uses: fastify/github-action-merge-dependabot@v3 41 | -------------------------------------------------------------------------------- /.github/workflows/notify-release.yml: -------------------------------------------------------------------------------- 1 | name: notify-release 2 | 'on': 3 | workflow_dispatch: 4 | schedule: 5 | - cron: 30 8 * * * 6 | release: 7 | types: 8 | - published 9 | issues: 10 | types: 11 | - closed 12 | jobs: 13 | setup: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | contents: read 18 | steps: 19 | - name: Notify release 20 | uses: nearform-actions/github-action-notify-release@v1 21 | with: 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | semver: 6 | description: The semver to use 7 | required: true 8 | default: patch 9 | type: choice 10 | options: 11 | - patch 12 | - minor 13 | - major 14 | pull_request: 15 | types: [closed] 16 | 17 | jobs: 18 | release: 19 | permissions: 20 | contents: write 21 | issues: write 22 | pull-requests: write 23 | id-token: write 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: nearform-actions/optic-release-automation-action@v4 27 | with: 28 | github-token: ${{ secrets.github_token }} 29 | npm-token: >- 30 | ${{ secrets[format('NPM_TOKEN_{0}', github.actor)] || 31 | secrets.NPM_TOKEN }} 32 | optic-token: >- 33 | ${{ secrets[format('OPTIC_TOKEN_{0}', github.actor)] || 34 | secrets.OPTIC_TOKEN }} 35 | semver: ${{ github.event.inputs.semver }} 36 | commit-message: 'chore: release {version}' 37 | build-command: npm i 38 | provenance: true 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # 0x 36 | .__browserify_string_empty.js 37 | profile-* 38 | 39 | # tap --cov 40 | .nyc_output/ 41 | 42 | # JetBrains IntelliJ IDEA 43 | .idea/ 44 | *.iml 45 | 46 | # VS Code 47 | .vscode/ 48 | package-lock.json 49 | yarn.lock -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # 0x 64 | .__browserify_string_empty.js 65 | profile-* 66 | 67 | # JetBrains IntelliJ IDEA 68 | .idea/ 69 | *.iml 70 | 71 | # VS Code 72 | .vscode/ 73 | 74 | # lock files 75 | package-lock.json 76 | yarn.lock 77 | 78 | # Travis configuration files 79 | ci_scripts/ 80 | .travis.yml 81 | 82 | # Git configuration files 83 | .gitattributes 84 | .gitignore 85 | .github 86 | .DS_Store -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "bracketSpacing": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.taprc: -------------------------------------------------------------------------------- 1 | 100: true 2 | reporter: spec 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at opensource@nearform.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to Mercurius Apollo Registry Plugin! 2 | 3 | Please take a second to read over this before opening an issue. Providing complete information upfront will help us address any issue (and ship new features!) faster. 4 | 5 | We greatly appreciate bug fixes, documentation improvements and new features, however when contributing a new major feature, it is a good idea to idea to first open an issue, to make sure the feature it fits with the goal of the project, so we don't waste your or our time. 6 | 7 | ## Bug Reports 8 | 9 | A perfect bug report would have the following: 10 | 11 | 1. Summary of the issue you are experiencing. 12 | 2. Details on what versions of node, mercurius and mercurius-apollo-registry you are using (`node -v`). 13 | 3. A simple repeatable test case for us to run. Please try to run through it 2-3 times to ensure it is completely repeatable. 14 | 15 | We would like to avoid issues that require a follow up questions to identify the bug. These follow ups are difficult to do unless we have a repeatable test case. 16 | 17 | ## For Developers 18 | 19 | All contributions should fit the linter, pass the tests and add new tests when new features are added. 20 | 21 | You can test this by running: 22 | 23 | ``` 24 | npm run lint 25 | npm run test 26 | ``` 27 | 28 | ### Test your changes 29 | 30 | To test your changes, create an Account in [Apollo Studio](https://studio.apollographql.com/). 31 | Then you can create a new graph application by adding an endpoint; feel free to use this `https://schema-reporting.api.apollographql.com/api/graphql`. 32 | Then move to the settings tab and select the `API Keys` option, create a new key and copy it. 33 | Use it in the `examples/basic.js` file. 34 | You can run the example with `node examples/basic.js` or `node --inspect examples/basic.js` to check the result of your development. 35 | 36 | --- 37 | 38 | 39 | 40 | ## Developer's Certificate of Origin 1.1 41 | 42 | By making a contribution to this project, I certify that: 43 | 44 | - (a) The contribution was created in whole or in part by me and I 45 | have the right to submit it under the open source license 46 | indicated in the file; or 47 | 48 | - (b) The contribution is based upon previous work that, to the best 49 | of my knowledge, is covered under an appropriate open source 50 | license and I have the right under that license to submit that 51 | work with modifications, whether created in whole or in part 52 | by me, under the same open source license (unless I am 53 | permitted to submit under a different license), as indicated 54 | in the file; or 55 | 56 | - (c) The contribution was provided directly to me by some other 57 | person who certified (a), (b) or (c) and I have not modified 58 | it. 59 | 60 | - (d) I understand and agree that this project and the contribution 61 | are public and that a record of the contribution (including all 62 | personal information I submit with it, including my sign-off) is 63 | maintained indefinitely and may be redistributed consistent with 64 | this project or the open source license(s) involved. 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 NearForm 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mercurius Apollo Registry Plugin 2 | 3 | A Mercurius plugin for schema reporting to Apollo Studio. 4 | 5 | ## Installation 6 | 7 | ``` 8 | npm install mercurius-apollo-registry 9 | ``` 10 | 11 | Please note that this plugin requires Mercurius as a dependency. 12 | 13 | ## Usage 14 | 15 | This plugin can be used to report a given GraphQL schema to the Apollo Studio Registry. 16 | 17 | ### Set up Apollo Studio 18 | 19 | In order to use this plugin, you should already have an account with Apollo Studio as well as at least one target graph already defined. Each graph has a unique API key associated with it that will be required by this plugin at start up. 20 | 21 | You can find more information about Apollo Studio [here](https://www.apollographql.com/docs/studio/getting-started/). 22 | 23 | ### Add plugin to your fastify instance 24 | 25 | ```js 26 | const mercuriusApolloRegistry = require('mercurius-apollo-registry') 27 | 28 | fastify.register(mercuriusApolloRegistry, { 29 | schema, 30 | apiKey 31 | }) 32 | 33 | ``` 34 | 35 | ### Plugin options 36 | 37 | - `schema` `string` (required) A stringified version of the GraphQL schema used by Mercurius. 38 | - `apiKey` `string` (required) API key for the specific graph you wish to reference in Apollo Studio. 39 | - `graphVariant` `string` (optional) The GraphQL variant to use in Apollo Studio. Defaults to `current`. 40 | - `registryUrl` `string` (optional) The registry API endpoint to use. Defaults to `https://schema-reporting.api.apollographql.com/api/graphql`. 41 | 42 | ## Registry Protocol 43 | 44 | A complete reference for the registry reporting protocol can be found in the [Apollo GraphQL Documentation](https://www.apollographql.com/docs/studio/schema/schema-reporting-protocol/). 45 | 46 | This plugin aims to allow integration and operability between Apollo Studio and Mercurius. 47 | 48 | ## Contributing 49 | 50 | See [CONTRIBUTING.md](./CONTRIBUTING.md) 51 | 52 | ## License 53 | 54 | Copyright NearForm Ltd 2021. Licensed under the [Apache-2.0 license](http://www.apache.org/licenses/LICENSE-2.0). 55 | -------------------------------------------------------------------------------- /examples/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | 6 | const mercuriusApolloRegistry = require('../') 7 | 8 | const app = Fastify() 9 | 10 | const books = [ 11 | { 12 | title: 'The Time Machine', 13 | author: 'HG Wells', 14 | price: 10.0 15 | }, 16 | { 17 | title: 'A Brief History of Time', 18 | author: 'Stephen Hawking', 19 | price: 12.0 20 | } 21 | ] 22 | 23 | const schema = ` 24 | type Book { 25 | title: String 26 | author: String 27 | price: Float 28 | } 29 | 30 | type Query { 31 | books: [Book] 32 | } 33 | ` 34 | 35 | const resolvers = { 36 | Query: { 37 | books: () => books 38 | } 39 | } 40 | 41 | app.register(mercurius, { 42 | schema, 43 | resolvers, 44 | graphiql: true 45 | }) 46 | 47 | app.register(mercuriusApolloRegistry, { 48 | schema, 49 | apiKey: 'update-with-your-graphs-api-key' 50 | }) 51 | 52 | app.listen(3000) 53 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const fetch = require('node-fetch') 5 | const { v4: uuidv4 } = require('uuid') 6 | 7 | const { getExecutableSchemaId, normalizeSchema } = require('./lib/util') 8 | const { initialQuery, reportQuery } = require('./lib/queries') 9 | 10 | const MAX_TIMEOUT_SEC = 3600 11 | const RETRY_TIMEOUT_SEC = 20 12 | const RETRY_RESPONSE = { 13 | inSeconds: RETRY_TIMEOUT_SEC, 14 | withExecutableSchema: false 15 | } 16 | 17 | const defaultRegistryUrl = 18 | 'https://schema-reporting.api.apollographql.com/api/graphql' 19 | const defaultGraphVariant = 'current' 20 | 21 | async function makeRegistryRequest({ 22 | registryUrl, 23 | apiKey, 24 | edgeServerInfo, 25 | executableSchema, 26 | log 27 | }) { 28 | const response = await fetch(registryUrl, { 29 | method: 'POST', 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | Accept: 'application/json', 33 | 'x-api-key': apiKey 34 | }, 35 | body: JSON.stringify({ 36 | query: executableSchema ? reportQuery : initialQuery, 37 | variables: { 38 | executableSchema, 39 | info: edgeServerInfo 40 | } 41 | }) 42 | }) 43 | 44 | if (!response.ok) { 45 | log.warn( 46 | `registry request failed with HTTP error response: ${response.status} ${response.statusText}` 47 | ) 48 | // Protocol requires us to try again in 20 seconds for non-2xx response. 49 | return RETRY_RESPONSE 50 | } 51 | 52 | const jsonData = await response.json() 53 | log.debug(jsonData, 'registry response') 54 | 55 | if (!jsonData || !jsonData.data || !jsonData.data.me) { 56 | log.warn('malformed registry response') 57 | 58 | // Retry request after timeout. 59 | return RETRY_RESPONSE 60 | } 61 | 62 | const { 63 | data: { me: report } 64 | } = jsonData 65 | 66 | if (report.reportServerInfo) { 67 | return report.reportServerInfo 68 | } 69 | 70 | // Protocol response doesn't match expected parameters 71 | // Retry request after timeout. 72 | log.warn(report, 'unknown registry response') 73 | return RETRY_RESPONSE 74 | } 75 | 76 | async function reporterLoop(fastify, options, edgeServerInfo) { 77 | let lastResponse 78 | let timeoutHandle 79 | let resolveTimeoutPromise 80 | 81 | fastify.addHook('onClose', (_, done) => { 82 | clearTimeout(timeoutHandle) 83 | timeoutHandle = null 84 | 85 | if (resolveTimeoutPromise) { 86 | resolveTimeoutPromise() 87 | } 88 | 89 | done() 90 | }) 91 | 92 | do { 93 | try { 94 | const executableSchema = 95 | lastResponse && lastResponse.withExecutableSchema 96 | ? options.schema 97 | : false 98 | 99 | fastify.log.debug( 100 | `making registry request with executableSchema: ${!!executableSchema}` 101 | ) 102 | 103 | lastResponse = await makeRegistryRequest({ 104 | ...options, 105 | edgeServerInfo, 106 | executableSchema, 107 | log: fastify.log 108 | }) 109 | 110 | if (lastResponse.inSeconds >= MAX_TIMEOUT_SEC) { 111 | fastify.log.warn( 112 | `registry timeout is greater than ${MAX_TIMEOUT_SEC} seconds. Possible registry or configuration issue. Trying again in ${RETRY_TIMEOUT_SEC} seconds.` 113 | ) 114 | lastResponse = RETRY_RESPONSE 115 | } 116 | 117 | fastify.log.debug( 118 | `waiting ${lastResponse.inSeconds} seconds until next registry request` 119 | ) 120 | 121 | await new Promise((resolve) => { 122 | resolveTimeoutPromise = resolve 123 | timeoutHandle = setTimeout(resolve, lastResponse.inSeconds * 1000) 124 | }) 125 | } catch (error) { 126 | fastify.log.error(error, 'fatal error occurred during registry update') 127 | throw error 128 | } 129 | } while (timeoutHandle && lastResponse) 130 | 131 | fastify.log.info('registry reporter has stopped') 132 | } 133 | 134 | const plugin = async function (fastify, opts) { 135 | if (!opts.apiKey) { 136 | throw new Error('an Apollo Studio API key is required') 137 | } 138 | 139 | if (typeof opts.schema !== 'string' || !opts.schema.length) { 140 | throw new Error('a schema string is required') 141 | } 142 | 143 | const options = { 144 | graphVariant: opts.graphVariant || defaultGraphVariant, 145 | registryUrl: opts.registryUrl || defaultRegistryUrl, 146 | schema: normalizeSchema(opts.schema), 147 | apiKey: opts.apiKey 148 | } 149 | 150 | const edgeServerInfo = { 151 | bootId: uuidv4(), 152 | executableSchemaId: getExecutableSchemaId(options.schema), 153 | graphVariant: options.graphVariant 154 | } 155 | 156 | fastify.log.debug(edgeServerInfo, 'generated edge server config') 157 | 158 | fastify.addHook('onReady', function () { 159 | reporterLoop(fastify, options, edgeServerInfo).catch((err) => { 160 | fastify.log.error(err) 161 | }) 162 | }) 163 | } 164 | 165 | module.exports = fp(plugin, { 166 | fastify: '4.x', 167 | name: 'mercuriusApolloRegistry', 168 | dependencies: ['mercurius'] 169 | }) 170 | -------------------------------------------------------------------------------- /lib/queries.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const initialQuery = ` 4 | mutation ReportServerInfo($info: EdgeServerInfo!) { 5 | me { 6 | __typename 7 | ... on ServiceMutation { 8 | reportServerInfo(info: $info) { 9 | inSeconds 10 | withExecutableSchema 11 | } 12 | } 13 | } 14 | } 15 | ` 16 | 17 | const reportQuery = ` 18 | mutation ReportServerInfo($info: EdgeServerInfo!, $executableSchema: String) { 19 | me { 20 | __typename 21 | ... on ServiceMutation { 22 | reportServerInfo(info: $info, executableSchema: $executableSchema) { 23 | inSeconds 24 | withExecutableSchema 25 | } 26 | } 27 | } 28 | }` 29 | 30 | module.exports = { 31 | initialQuery, 32 | reportQuery 33 | } 34 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('crypto') 4 | 5 | function normalizeSchema(schema) { 6 | return schema 7 | .replace(/(\r\n|\n|\r)/gm, '') 8 | .replace(/\s+/g, ' ') 9 | .trim() 10 | } 11 | 12 | function getExecutableSchemaId(schema) { 13 | return crypto.createHash('sha256').update(schema).digest('hex') 14 | } 15 | 16 | module.exports = { 17 | getExecutableSchemaId, 18 | normalizeSchema 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mercurius-apollo-registry", 3 | "version": "2.1.4", 4 | "description": "A schema reporting plugin for Mercurius", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "tap test", 8 | "test:ci": "tap --coverage-report=lcov test", 9 | "lint": "eslint .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "lint:staged": "lint-staged" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+ssh://git@github.com:nearform/mercurius-apollo-registry.git" 16 | }, 17 | "keywords": [ 18 | "mercurius", 19 | "graphql", 20 | "apollo", 21 | "schema", 22 | "reporter" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/nearform/mercurius-apollo-registry/issues" 26 | }, 27 | "homepage": "https://github.com/nearform/mercurius-apollo-registry#readme", 28 | "engines": { 29 | "node": ">=12" 30 | }, 31 | "author": "Nigel Hanlon ", 32 | "license": "Apache-2.0", 33 | "dependencies": { 34 | "fastify-plugin": "^4.0.0", 35 | "node-fetch": "^2.6.1", 36 | "uuid": "^10.0.0" 37 | }, 38 | "devDependencies": { 39 | "eslint": "^8.18.0", 40 | "eslint-config-prettier": "^9.0.0", 41 | "eslint-config-standard": "^17.0.0", 42 | "eslint-plugin-import": "^2.22.1", 43 | "eslint-plugin-n": "^16.0.0", 44 | "eslint-plugin-prettier": "^5.0.0", 45 | "eslint-plugin-promise": "^6.0.0", 46 | "eslint-plugin-standard": "^5.0.0", 47 | "faker": "^5.2.0", 48 | "fastify": "^4.0.1", 49 | "husky": "^9.0.11", 50 | "lint-staged": "^15.2.0", 51 | "prettier": "^3.0.1", 52 | "proxyquire": "^2.1.3", 53 | "sinon": "^18.0.0", 54 | "tap": "^16.0.0" 55 | }, 56 | "peerDependencies": { 57 | "mercurius": "^13.0.0" 58 | }, 59 | "lint-staged": { 60 | "*.js": [ 61 | "eslint --fix", 62 | "prettier --write" 63 | ] 64 | }, 65 | "husky": { 66 | "hooks": { 67 | "pre-commit": "npm run lint:staged", 68 | "pre-push": "npm run lint && npm run test" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const test = tap.test 5 | const Fastify = require('fastify') 6 | const fp = require('fastify-plugin') 7 | const proxyquire = require('proxyquire') 8 | const sinon = require('sinon') 9 | const faker = require('faker') 10 | 11 | const { initialQuery, reportQuery } = require('../lib/queries') 12 | 13 | const RETRY_TIMEOUT = 20 14 | 15 | const clock = sinon.useFakeTimers() 16 | 17 | function makeStubMercurius() { 18 | return fp(async () => {}, { 19 | name: 'mercurius' 20 | }) 21 | } 22 | 23 | test('plugin registration', async (t) => { 24 | t.beforeEach(async () => { 25 | const fetchMock = sinon.stub().resolves({ 26 | ok: true, 27 | json: sinon.stub().resolves({ 28 | data: { 29 | me: { 30 | reportServerInfo: { 31 | inSeconds: 180, 32 | withExecutableSchema: false 33 | } 34 | } 35 | } 36 | }) 37 | }) 38 | 39 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 40 | 41 | const fastify = Fastify() 42 | 43 | fastify.register(makeStubMercurius()) 44 | 45 | t.context.fastify = fastify 46 | t.context.plugin = plugin 47 | }) 48 | 49 | t.afterEach(async () => { 50 | return t.teardown(t.context.fastify.close.bind(t.context.fastify)) 51 | }) 52 | 53 | t.test('plugin should exist and load without error', async (t) => { 54 | const { fastify, plugin } = t.context 55 | 56 | fastify.register(plugin, { 57 | apiKey: faker.random.uuid(), 58 | schema: faker.lorem.paragraph(), 59 | registryUrl: faker.internet.url() 60 | }) 61 | 62 | return fastify.ready() 63 | }) 64 | 65 | t.test('plugin should throw an error if schema is missing', async (t) => { 66 | const { fastify, plugin } = t.context 67 | 68 | fastify.register(plugin, { 69 | apiKey: faker.random.uuid(), 70 | registryUrl: faker.internet.url() 71 | }) 72 | 73 | return t.rejects(() => fastify.ready(), 'a schema string is required') 74 | }) 75 | 76 | t.test('plugin should throw an error if schema is missing', async (t) => { 77 | const { fastify, plugin } = t.context 78 | 79 | fastify.register(plugin, { 80 | apiKey: faker.random.uuid(), 81 | registryUrl: faker.internet.url(), 82 | schema: '' 83 | }) 84 | 85 | return t.rejects(() => fastify.ready(), 'a schema string is required') 86 | }) 87 | 88 | t.test('plugin should throw an error if api key is missing', async (t) => { 89 | const { fastify, plugin } = t.context 90 | 91 | fastify.register(plugin, { 92 | schema: faker.lorem.paragraph(), 93 | registryUrl: faker.internet.url() 94 | }) 95 | 96 | return t.rejects( 97 | () => fastify.ready(), 98 | 'an Apollo Studio API key is required' 99 | ) 100 | }) 101 | 102 | t.test('registryUrl should be optional', async (t) => { 103 | const { fastify, plugin } = t.context 104 | 105 | fastify.register(plugin, { 106 | apiKey: faker.random.uuid(), 107 | schema: faker.lorem.paragraph() 108 | }) 109 | 110 | return fastify.ready() 111 | }) 112 | }) 113 | 114 | test('apollo registry api requests', async (t) => { 115 | t.beforeEach(async () => { 116 | const fastify = Fastify() 117 | fastify.register(makeStubMercurius()) 118 | 119 | t.context.fastify = fastify 120 | t.context.opts = { 121 | apiKey: faker.random.uuid(), 122 | schema: faker.lorem.paragraph(), 123 | registryUrl: faker.internet.url() 124 | } 125 | }) 126 | 127 | t.afterEach(async () => { 128 | return t.teardown(t.context.fastify.close.bind(t.context.fastify)) 129 | }) 130 | 131 | t.test( 132 | 'invokes the api with executableSchema false and the initial query', 133 | async (t) => { 134 | const { fastify, opts } = t.context 135 | 136 | const REGISTRY_TIMEOUT = 60 137 | const fetchMock = sinon.stub().resolves({ 138 | ok: true, 139 | json: sinon.stub().resolves({ 140 | data: { 141 | me: { 142 | reportServerInfo: { 143 | inSeconds: REGISTRY_TIMEOUT, 144 | withExecutableSchema: true 145 | } 146 | } 147 | } 148 | }) 149 | }) 150 | 151 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 152 | fastify.register(plugin, opts) 153 | 154 | await fastify.ready() 155 | 156 | const requestInit = fetchMock.getCalls()[0].args[1] 157 | 158 | sinon.assert.match(requestInit.headers, { 'x-api-key': opts.apiKey }) 159 | 160 | const parsedBody = JSON.parse(requestInit.body) 161 | 162 | sinon.assert.match(parsedBody, { 163 | query: initialQuery, 164 | variables: { 165 | executableSchema: false, 166 | info: sinon.match.object 167 | } 168 | }) 169 | } 170 | ) 171 | 172 | t.test( 173 | 'runs the next iteration only when the inSeconds from the response have elapsed', 174 | async (t) => { 175 | const { fastify, opts } = t.context 176 | 177 | const REGISTRY_TIMEOUT = 60 178 | 179 | const fetchMock = sinon.stub().resolves({ 180 | ok: true, 181 | json: sinon.stub().resolves({ 182 | data: { 183 | me: { 184 | reportServerInfo: { 185 | inSeconds: REGISTRY_TIMEOUT, 186 | withExecutableSchema: true 187 | } 188 | } 189 | } 190 | }) 191 | }) 192 | 193 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 194 | fastify.register(plugin, opts) 195 | 196 | await fastify.ready() 197 | 198 | t.equal(fetchMock.getCalls().length, 1) 199 | 200 | // advance time by REGISTRY_TIMEOUT - 2 seconds 201 | await clock.tickAsync((REGISTRY_TIMEOUT - 2) * 1000) 202 | t.equal(fetchMock.getCalls().length, 1) 203 | 204 | // advance time to REGISTRY_TIMEOUT 205 | await clock.tickAsync(REGISTRY_TIMEOUT * 1000) 206 | t.equal(fetchMock.getCalls().length, 2) 207 | 208 | const requestInit = fetchMock.getCalls()[1].args[1] 209 | 210 | sinon.assert.match(requestInit.headers, { 'x-api-key': opts.apiKey }) 211 | 212 | const parsedBody = JSON.parse(requestInit.body) 213 | 214 | sinon.assert.match(parsedBody, { 215 | query: reportQuery, 216 | variables: { 217 | executableSchema: opts.schema, 218 | info: sinon.match.object 219 | } 220 | }) 221 | } 222 | ) 223 | 224 | t.test( 225 | 'runs the next iteration sooner than the MAX_TIMEOUT reported by the registry', 226 | async (t) => { 227 | const { fastify, opts } = t.context 228 | 229 | // 24 Hour timeout 230 | const REGISTRY_TIMEOUT = 86400 231 | 232 | const fetchMock = sinon.stub().resolves({ 233 | ok: true, 234 | json: sinon.stub().resolves({ 235 | data: { 236 | me: { 237 | reportServerInfo: { 238 | inSeconds: REGISTRY_TIMEOUT, 239 | withExecutableSchema: false 240 | } 241 | } 242 | } 243 | }) 244 | }) 245 | 246 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 247 | fastify.register(plugin, opts) 248 | 249 | await fastify.ready() 250 | 251 | // initial call to registry 252 | sinon.assert.calledOnce(fetchMock) 253 | 254 | // advance time to after RETRY_TIMEOUT 255 | await clock.tickAsync((RETRY_TIMEOUT + 10) * 1000) 256 | sinon.assert.calledTwice(fetchMock) 257 | 258 | const requestInit = fetchMock.getCalls()[1].args[1] 259 | const parsedBody = JSON.parse(requestInit.body) 260 | 261 | sinon.assert.match(parsedBody, { 262 | query: initialQuery, 263 | variables: { 264 | executableSchema: false, 265 | info: sinon.match.object 266 | } 267 | }) 268 | } 269 | ) 270 | 271 | t.test( 272 | 'plugin retries after a failed registry request (non 200)', 273 | async (t) => { 274 | const { fastify, opts } = t.context 275 | 276 | const fetchMock = sinon.stub().resolves({ ok: false }) 277 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 278 | fastify.register(plugin, opts) 279 | 280 | await fastify.ready() 281 | 282 | // Initial call made? 283 | sinon.assert.calledOnce(fetchMock) 284 | 285 | // advance time by RETRY_TIMEOUT - 2 seconds 286 | await clock.tickAsync((RETRY_TIMEOUT - 2) * 1000) 287 | sinon.assert.calledOnce(fetchMock) 288 | 289 | // advance time to after RETRY_TIMEOUT 290 | await clock.tickAsync(RETRY_TIMEOUT * 1000) 291 | sinon.assert.calledTwice(fetchMock) 292 | } 293 | ) 294 | 295 | t.test('plugin retries after a malformed registry response', async (t) => { 296 | const { fastify, opts } = t.context 297 | 298 | const fetchMock = sinon.stub().resolves({ 299 | ok: true, 300 | json: sinon.stub().resolves({ foo: 'bar' }) 301 | }) 302 | 303 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 304 | fastify.register(plugin, opts) 305 | 306 | await fastify.ready() 307 | 308 | // Initial call made? 309 | sinon.assert.calledOnce(fetchMock) 310 | 311 | // advance time by RETRY_TIMEOUT - 2 seconds 312 | await clock.tickAsync((RETRY_TIMEOUT - 2) * 1000) 313 | sinon.assert.calledOnce(fetchMock) 314 | 315 | // advance time to after RETRY_TIMEOUT 316 | await clock.tickAsync(RETRY_TIMEOUT * 1000) 317 | sinon.assert.calledTwice(fetchMock) 318 | }) 319 | 320 | t.test('plugin retries after an unknown registry response', async (t) => { 321 | const { fastify, opts } = t.context 322 | const fetchMock = sinon.stub().resolves({ 323 | ok: true, 324 | json: sinon.stub().resolves({ 325 | data: { 326 | me: { 327 | foo: 'bar' 328 | } 329 | } 330 | }) 331 | }) 332 | 333 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 334 | fastify.register(plugin, opts) 335 | 336 | await fastify.ready() 337 | 338 | // Initial call made? 339 | sinon.assert.calledOnce(fetchMock) 340 | 341 | // advance time by RETRY_TIMEOUT - 2 seconds 342 | await clock.tickAsync((RETRY_TIMEOUT - 2) * 1000) 343 | sinon.assert.calledOnce(fetchMock) 344 | 345 | // advance time to after RETRY_TIMEOUT 346 | await clock.tickAsync(RETRY_TIMEOUT * 1000) 347 | sinon.assert.calledTwice(fetchMock) 348 | }) 349 | 350 | t.test('plugin exits after a fatal exception', async (t) => { 351 | const { fastify, opts } = t.context 352 | const fetchMock = sinon.stub().throws(new Error('fetch error')) 353 | 354 | const plugin = proxyquire('../', { 'node-fetch': fetchMock }) 355 | fastify.register(plugin, opts) 356 | 357 | await fastify.ready() 358 | 359 | sinon.assert.calledOnce(fetchMock) 360 | 361 | // Ensure plugin has exited on exception by checking 362 | // there are no further retries. 363 | await clock.tickAsync(RETRY_TIMEOUT * 2 * 1000) 364 | 365 | sinon.assert.calledOnce(fetchMock) 366 | }) 367 | }) 368 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const tap = require('tap') 4 | const test = tap.test 5 | 6 | const { getExecutableSchemaId, normalizeSchema } = require('../lib/util') 7 | 8 | test('getExecutableSchemaId tests', async (t) => { 9 | t.test( 10 | 'the correct schema hash is computed for a given string', 11 | async (t) => { 12 | const schema = 'this is a schema' 13 | const expectedSHA = 14 | '58240a12c29ea996000f31517c5c76a371b8a76b0ad1967180cf3b7c1cb311b4' 15 | 16 | t.equal(getExecutableSchemaId(schema), expectedSHA) 17 | } 18 | ) 19 | }) 20 | 21 | test('normalizeSchema tests', async (t) => { 22 | t.test( 23 | 'a normalized schema is returned for a given schema string', 24 | async (t) => { 25 | const schema = ` 26 | type Book { 27 | title: String 28 | author: String 29 | price: Float 30 | } 31 | 32 | type Query { 33 | books: [Book] 34 | } 35 | ` 36 | const normalizedString = 37 | 'type Book { title: String author: String price: Float } type Query { books: [Book] }' 38 | t.equal(normalizeSchema(schema), normalizedString) 39 | } 40 | ) 41 | 42 | t.test('normalizeSchema removes additional white space', async (t) => { 43 | const schema = 'this has extra whitespace' 44 | const normalizedString = 'this has extra whitespace' 45 | t.equal(normalizeSchema(schema), normalizedString) 46 | }) 47 | 48 | t.test('normalizeSchema removes new line characters', async (t) => { 49 | const schema = ` 50 | this 51 | has 52 | extra 53 | newlines 54 | ` 55 | const normalizedString = 'this has extra newlines' 56 | t.equal(normalizeSchema(schema), normalizedString) 57 | }) 58 | }) 59 | --------------------------------------------------------------------------------