├── .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 |
--------------------------------------------------------------------------------