@usehenri/graphql 0.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # @usehenri/graphql
2
+
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#404](https://github.com/usehenri/henri/pull/404) [`ab5a8e4`](https://github.com/usehenri/henri/commit/ab5a8e4a88c80cca070a2b8ad398c80babdaff11) Thanks [@reel](https://github.com/reel)! - A model's GraphQL definition is derived from the schema it already declares.
8
+
9
+ A model that wanted GraphQL wrote its types out by hand, in SDL, immediately below the henri schema that had just said the same thing — the field names again, the types again, and the resolvers to go with them ([#68](https://github.com/usehenri/henri/issues/68), open since 2019). `graphql: true` is now the whole key:
10
+
11
+ ```js
12
+ module.exports = {
13
+ graphql: true,
14
+ schema: {
15
+ title: { type: 'string', required: true },
16
+ year: { type: 'integer' },
17
+ },
18
+ };
19
+ ```
20
+
21
+ which serves `type Artwork { id: ID! title: String! year: Int ... }`, `artwork(id: ID!)` and `artworks(page:, perPage:, where:)` — an `ArtworkPage`, so no derived query is unbounded.
22
+
23
+ **It is derived at boot, not written into the file, and that is the decision worth stating.** A generator would have handed over an honest copy that stops being true the first time a column, a `personal` mark, an `encrypted` mark or `config.externalIds` changes; the definition depends on all four. henri already refuses that trade for the OpenAPI document, for the HAL `_links` and for what a foreign key publishes as. `henri graphql` prints the derived SDL without booting — paste it into a model's own `types` when you want to own it — and `henri graphql --summary` says what was left out of each model and why.
24
+
25
+ **`id` is the `externalId`.** The primary key is not a field, it is not an argument, and `artwork(id:)` resolves through `findById()`, which takes the public identifier and nothing else. A declared foreign key is an `ID` carrying the target row's `externalId`, and a mutation writing one takes an `externalId` back and looks the row up: the identifier rule, in both directions.
26
+
27
+ **What is never derived is read off the model, not off a list of names.** A field marked `personal: { expose: false }` is not a field — which is what leaves the user's `password` out, since `base/privacy.js` marks it that way on every application with a user model. A field marked `personal: true` is a field and never an argument: a value you may read on a record you are allowed to see is not one anybody may search by. A randomised `encrypted` field is never an argument either, because henri refuses that query with `HENRI_ENCRYPTION_NOT_QUERYABLE` and a field the framework will not query has no business being a queryable one. A `json` column has no shape GraphQL could state, so it is left for a scalar of your own.
28
+
29
+ **Queries by default, mutations on request.** `graphql: { generate: true, mutations: true }` adds `createArtwork`, `updateArtwork` and `deleteArtwork`; nothing writes unless a model asks, because a delete mutation on the endpoint of an application that never wanted one is a hole. The block also takes `name`, `queries`, `filters`, `except`, and the `types` and `resolvers` you write yourself, which are merged on top of the derived ones and win.
30
+
31
+ **Every derived resolver goes through `app/policies`, and there is no setting that turns that off.** One record asks `show`; a refusal and a row that is not there both answer `null`, the same non-oracle `findById()` follows. A list asks `index` and then asks the policy what the list _is_ — `scope(user)` is the condition it filters by, and a `where` argument narrows that and never widens it. Mutations ask `create`, `update` and `destroy`. Policies fail closed, so a model with `graphql: true` and no `app/policies/<model>.js` serves an empty page and a null record: opting a model into GraphQL is not opting it out of authorization. Everything answered is published and stripped by the same two functions every other answer goes through.
32
+
33
+ `henri doctor` reports the three things that are otherwise invisible: a `graphql` key that would fail the boot (`graphql.declaration`), a derived model with no policy behind it or a policy with no `scope(user)` behind its list query (`graphql.policy`), and a hand-written definition naming a field marked `personal: { expose: false }` (`graphql.exposed`) — the drift a derived definition cannot have.
34
+
35
+ A model that writes `graphql: { types, resolvers }` is untouched: nothing is derived unless `generate` asks for it. Four codes are new: `HENRI_API_GRAPHQL_INVALID_DECLARATION`, `HENRI_API_GRAPHQL_SCOPE_REQUIRED`, `HENRI_API_GRAPHQL_DENIED` and `HENRI_API_GRAPHQL_UNKNOWN_REFERENCE`. The [GraphQL guide](https://usehenri.io/guides/graphql/) has the whole table.
36
+
37
+ - [#351](https://github.com/usehenri/henri/pull/351) [`fda9366`](https://github.com/usehenri/henri/commit/fda9366e9ed2b072764a995c5aa60205ca7a4725) Thanks [@reel](https://github.com/reel)! - GraphQL moves out of core into `@usehenri/graphql`
38
+
39
+ The GraphQL layer is now a package of its own. `@usehenri/core` no longer
40
+ depends on `@apollo/server`, `@as-integrations/express5`,
41
+ `@graphql-tools/merge`, `@graphql-tools/schema` or `graphql`, so an
42
+ application that never mounts a schema stops installing them.
43
+
44
+ An application that uses GraphQL adds one dependency,
45
+ `npm install @usehenri/graphql`, and nothing else changes: the package ships
46
+ the henri module itself, so depending on it is what puts `henri.graphql` in
47
+ the boot, with the same `run()`, `endpoint`, `active`, error classes and
48
+ `toApolloError()`. The endpoint is still `/_henri/gql` (still configurable
49
+ with the `graphql` key) and the schema is still built from the models'
50
+ `graphql` keys.
51
+
52
+ `@usehenri/core/module` is the base class a module package extends, and this
53
+ is the first package to use it: it is the supported path, so a module of your
54
+ own no longer reaches into `@usehenri/core/src/base/module`.
55
+
56
+ Without the package henri says so instead of going quiet: a model declaring a
57
+ `graphql` key fails the boot with the install line, `res.render(view, { graphql })`
58
+ fails the request with it, and `henri doctor` reports it as a missing
59
+ dependency. `henri.graphql` is `undefined` rather than an object that does
60
+ nothing, which the type declarations say too, and a page has no `graphql` key
61
+ among its view options.
62
+
63
+ ### Patch Changes
64
+
65
+ - Updated dependencies [[`792a15a`](https://github.com/usehenri/henri/commit/792a15ade614cf8b920d9197586f9866700d458e), [`1e23664`](https://github.com/usehenri/henri/commit/1e23664829bd1a356de28f404cfb21c9ae211388), [`5b627ad`](https://github.com/usehenri/henri/commit/5b627adfa37e9f16bc75af96cc8ff5308a91f688), [`4dff51e`](https://github.com/usehenri/henri/commit/4dff51edc050e29398c793a5aedb48776b7b7119), [`60dbf33`](https://github.com/usehenri/henri/commit/60dbf33c2a4f14e328a0df1cb44be061e15431e5), [`1b316c6`](https://github.com/usehenri/henri/commit/1b316c6f3c5d5eb5752c70b534092d1052956cc6), [`d074e8b`](https://github.com/usehenri/henri/commit/d074e8b482582e25f80d8a14b4735e69a2b7821e), [`7fd13f6`](https://github.com/usehenri/henri/commit/7fd13f631b75f7aa152b73046b50c6902ae3ca93), [`b559fb7`](https://github.com/usehenri/henri/commit/b559fb72b391eeb21a3f6a0cda1515e01ecbfafc), [`1c0dfe8`](https://github.com/usehenri/henri/commit/1c0dfe84a98eff2122512256c4f42ec7ccde4212), [`93060a8`](https://github.com/usehenri/henri/commit/93060a86df795dbbd99bf1895beb0cf14c4b86de), [`e031900`](https://github.com/usehenri/henri/commit/e031900082f28aec72af4cda9cd959f932e2ebc7), [`9173000`](https://github.com/usehenri/henri/commit/91730005efa88f073bfaaf67078c3ec0e137b459), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`b7f33e2`](https://github.com/usehenri/henri/commit/b7f33e28a5e4844391befd75d08e42c4cf6212ed), [`3d6f3fc`](https://github.com/usehenri/henri/commit/3d6f3fc048d05db41be86069608d342e437408cb), [`b278119`](https://github.com/usehenri/henri/commit/b2781190de436eb5838e866446c9a0c8210bb6ca), [`b161e1b`](https://github.com/usehenri/henri/commit/b161e1b8fad94af2d2afc351dc1bc07dabbb1379), [`7cb0b04`](https://github.com/usehenri/henri/commit/7cb0b04b29b61dedaa82fcd1972646fb3765acfc), [`1616e34`](https://github.com/usehenri/henri/commit/1616e343a612be2bffffcfa5b23bfa8ad191bbe3), [`c8f5367`](https://github.com/usehenri/henri/commit/c8f53678b33341d086b467f801e959314afc7860), [`bcf4ce2`](https://github.com/usehenri/henri/commit/bcf4ce22bcd294844504164fac1fa4aef1ffec41), [`ab5a8e4`](https://github.com/usehenri/henri/commit/ab5a8e4a88c80cca070a2b8ad398c80babdaff11), [`43d267f`](https://github.com/usehenri/henri/commit/43d267f0f9d192b2c01e89c3925b7daf5000041b), [`9f868f3`](https://github.com/usehenri/henri/commit/9f868f3d9162fa218e34304110210e6949f97d5c), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`89dda62`](https://github.com/usehenri/henri/commit/89dda62da456a0a55600e79cfb65ce89f11258e2), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`a93d6cc`](https://github.com/usehenri/henri/commit/a93d6cc39b33b261089e91f3e757b54fefc9fe15), [`d9f3be4`](https://github.com/usehenri/henri/commit/d9f3be49c5929d929a220220bf6e72fdcb135595), [`c44f025`](https://github.com/usehenri/henri/commit/c44f025acec3d5bbbb57e2310d02184a1053a10d), [`67cfb20`](https://github.com/usehenri/henri/commit/67cfb200ea0e0b31bacf2af183db6467b0fa011d), [`e661f98`](https://github.com/usehenri/henri/commit/e661f98fe8f8acce15aa10ce2dc320c5a2cb006f), [`43a0e1a`](https://github.com/usehenri/henri/commit/43a0e1a6a320baa43298391e1c1e0334d6cd28d5), [`cee57b9`](https://github.com/usehenri/henri/commit/cee57b9d3521a4a70c715222eae1f18ff4a6c128), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`2c8a826`](https://github.com/usehenri/henri/commit/2c8a8265262dbf6ea5c3e73e8e7892a230d4d0f0), [`46d5dbc`](https://github.com/usehenri/henri/commit/46d5dbcc983c03e96ae5a87d7288c5d8a5adbc24), [`ba97ea9`](https://github.com/usehenri/henri/commit/ba97ea968f0b34cd67b7a3e803ecd34543b8aaaf), [`5ccd537`](https://github.com/usehenri/henri/commit/5ccd537b3621b54d11e7f24ccca39643ae7d5cf7), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`9895cbf`](https://github.com/usehenri/henri/commit/9895cbf4be85b476e341a5be915e3049e5a027de), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`5a150d5`](https://github.com/usehenri/henri/commit/5a150d576208571c32b9cd12827d035e31ed4313), [`3c1c5b8`](https://github.com/usehenri/henri/commit/3c1c5b83ea135b000ddd6ffbfd05457b000f2f7c), [`2625067`](https://github.com/usehenri/henri/commit/26250673d91ab70ad024739d02b647754f75267d), [`aa3f90b`](https://github.com/usehenri/henri/commit/aa3f90bd6f42bb05431c33f3e4bf2202cb6bb7c6), [`a1c6769`](https://github.com/usehenri/henri/commit/a1c6769099e3dc28b22b5338a2a57b13bdf69f7a), [`0a8bb41`](https://github.com/usehenri/henri/commit/0a8bb415d352cd75b12d07e591c8ec7c16774a99), [`ec1c8c4`](https://github.com/usehenri/henri/commit/ec1c8c419f4d9063a7617472b2970fdb8a929fa1), [`dd2731d`](https://github.com/usehenri/henri/commit/dd2731d6a20fd96aa1be1aeb5e6ec0155001326b), [`ab52e18`](https://github.com/usehenri/henri/commit/ab52e187c420dfe381f03ed51c5c141fda525acb), [`b7b56e1`](https://github.com/usehenri/henri/commit/b7b56e190ae774abc0096fe2aebaf91f823115af), [`b7038ce`](https://github.com/usehenri/henri/commit/b7038ceaa430f4a0b9eaf7e983fc2844421bf636), [`1ea0f85`](https://github.com/usehenri/henri/commit/1ea0f85066b86fba31f58937cc10abb6359e6a26), [`762062a`](https://github.com/usehenri/henri/commit/762062aadc450d49b1a2d15524f9d579ab4f60e7), [`01a561a`](https://github.com/usehenri/henri/commit/01a561aa58650ec15df1c2659795a5e4c5bbfd53), [`bd1b630`](https://github.com/usehenri/henri/commit/bd1b63083b3817b8c47b8a187de76027458a1b32), [`27b5513`](https://github.com/usehenri/henri/commit/27b5513d1cae8aba734fe27da0ace2a82423e2f4), [`e31a3f7`](https://github.com/usehenri/henri/commit/e31a3f73e7e8facf3cedf7460f115e57995f32c3), [`2689779`](https://github.com/usehenri/henri/commit/26897798b840fd28a4bc091c050a83457b36905d), [`72cd1d3`](https://github.com/usehenri/henri/commit/72cd1d35ffb99311bbca815c1f6ab41ee3682f64), [`a2e1ec2`](https://github.com/usehenri/henri/commit/a2e1ec29df52462f12ebaae9bfbc1ad4f427b27f), [`afead74`](https://github.com/usehenri/henri/commit/afead7489498ed42e1893a25123ea772cac2ca09), [`a4ecba5`](https://github.com/usehenri/henri/commit/a4ecba50c663f4d5c741adbb6cd9bc0eefe0e5cc), [`baec3fd`](https://github.com/usehenri/henri/commit/baec3fd22be92bf8ffbaeb251b0b6c2771f8347a), [`1103628`](https://github.com/usehenri/henri/commit/110362808f8ec6d73a75ff7fc89a77f3e943d773), [`e865d94`](https://github.com/usehenri/henri/commit/e865d945d65419ac676f5a2fe3ba3b6114a1e53d), [`4274567`](https://github.com/usehenri/henri/commit/4274567e20a980657f07df9ec7db25296c7d55f5), [`aea429c`](https://github.com/usehenri/henri/commit/aea429ca99338a62370ab3e3d94bdc6b8c227601), [`8a8e3b3`](https://github.com/usehenri/henri/commit/8a8e3b33d7967b81f66633aa25c3075318f01d60), [`18715f9`](https://github.com/usehenri/henri/commit/18715f90ea8958dc57da1bb029b8209c36b84cc6), [`0d2ebc3`](https://github.com/usehenri/henri/commit/0d2ebc344bfcd80533ef900638083e4c105407bb), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`ec64e44`](https://github.com/usehenri/henri/commit/ec64e44e7b79a02da5fc587a72a9a6c900836982), [`831aa5c`](https://github.com/usehenri/henri/commit/831aa5c011f3432630c68b8d26755d2582f82f74), [`16824e8`](https://github.com/usehenri/henri/commit/16824e8fe9ccc6a04dab5d9b2481c29ff4f6b64b), [`fda9366`](https://github.com/usehenri/henri/commit/fda9366e9ed2b072764a995c5aa60205ca7a4725), [`1a86acb`](https://github.com/usehenri/henri/commit/1a86acbf15e4a43e5fb81277bb22e101c06e77a4), [`808d824`](https://github.com/usehenri/henri/commit/808d82471d59e64ccc735f617bab293eb572c46b), [`0b32fbd`](https://github.com/usehenri/henri/commit/0b32fbde19c95da8fe07fab76933840a4242c71c), [`c0c16e8`](https://github.com/usehenri/henri/commit/c0c16e873ba440aee9832160553cbb12ab81bd2c), [`41470bf`](https://github.com/usehenri/henri/commit/41470bf378d83ca3d35d00e8c31796fea5eb15e0), [`8e44e7e`](https://github.com/usehenri/henri/commit/8e44e7e882dd8741b3ac632651b453389d76bf2c), [`de1c1e0`](https://github.com/usehenri/henri/commit/de1c1e02ed83d13dcfeb8e44012f309eb663f03e), [`4b4677d`](https://github.com/usehenri/henri/commit/4b4677d4a09d39fe50b1fa4af577600342578daf)]:
66
+ - @usehenri/core@1.2.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016-present, Félix-Antoine Paradis
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.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
+ <!-- generated by scripts/prepublish.js -->
2
+
1
3
  # @usehenri/graphql
2
4
 
3
- Placeholder release. Install a real version: see https://usehenri.io.
5
+ Henri GraphQL: the types and resolvers of the models, merged and served by Apollo Server.
6
+
7
+ Part of [henri](https://usehenri.io), the Rails-like React framework for
8
+ Node.js: [documentation](https://usehenri.io),
9
+ [source and issues](https://github.com/usehenri/henri),
10
+ [changelog](https://github.com/usehenri/henri/blob/master/packages/graphql/CHANGELOG.md).
package/index.js ADDED
@@ -0,0 +1,18 @@
1
+ const Graphql = require('./src/graphql');
2
+
3
+ /**
4
+ * GraphQL for henri.
5
+ *
6
+ * `@usehenri/core` resolves this package from the application directory the
7
+ * way it resolves a store adapter, and exposes what it builds as
8
+ * `henri.graphql`. An application that does not install it never loads
9
+ * Apollo Server, and core says so the moment something asks for a query.
10
+ *
11
+ * @param {object} henri A henri instance
12
+ * @returns {Graphql} The engine
13
+ */
14
+ const create = (henri) => new Graphql(henri);
15
+
16
+ module.exports = create;
17
+ module.exports.Graphql = Graphql;
18
+ module.exports.create = create;
package/module.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The henri module this package ships.
3
+ *
4
+ * `package.json` points at this file with `"henri": { "module": "./module.js" }`,
5
+ * which is all core reads: an application depending on `@usehenri/graphql`
6
+ * has the module in its boot, as `henri.graphql`.
7
+ */
8
+ module.exports = require('./src/graphql');
package/package.json CHANGED
@@ -1,15 +1,56 @@
1
1
  {
2
- "description": "Placeholder creating @usehenri/graphql on npm; the first real version is published by the henri release workflow",
3
- "homepage": "https://usehenri.io",
4
- "license": "MIT",
5
2
  "name": "@usehenri/graphql",
6
- "publishConfig": {
7
- "access": "public"
8
- },
3
+ "version": "1.2.0",
4
+ "description": "henri GraphQL: the types and resolvers of the models, merged and served by Apollo Server",
5
+ "license": "MIT",
6
+ "author": "Felix-Antoine Paradis",
7
+ "homepage": "https://usehenri.io",
9
8
  "repository": {
10
- "directory": "packages/graphql",
11
9
  "type": "git",
12
- "url": "git+https://github.com/usehenri/henri.git"
10
+ "url": "git+https://github.com/usehenri/henri.git",
11
+ "directory": "packages/graphql"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/usehenri/henri/issues"
15
+ },
16
+ "keywords": [
17
+ "henri",
18
+ "graphql",
19
+ "apollo",
20
+ "schema",
21
+ "resolvers"
22
+ ],
23
+ "main": "index.js",
24
+ "henri": {
25
+ "module": "./module.js"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "module.js",
30
+ "src",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "provenance": true
36
+ },
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "dependencies": {
41
+ "@apollo/server": "^5.2.0",
42
+ "@as-integrations/express5": "^1.1.2",
43
+ "@graphql-tools/merge": "^9.2.3",
44
+ "@graphql-tools/schema": "^10.1.0",
45
+ "debug": "^4.4.3",
46
+ "graphql": "^16.11.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@usehenri/core": "^1.2.0"
13
50
  },
14
- "version": "0.0.0"
15
- }
51
+ "devDependencies": {
52
+ "@usehenri/core": "^1.2.0",
53
+ "express": "^5.2.1",
54
+ "supertest": "^7.2.2"
55
+ }
56
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Bounds on the GraphQL endpoint.
3
+ *
4
+ * A rate limiter caps how many requests arrive. It says nothing about what
5
+ * one request costs, and one GraphQL request can cost arbitrarily much: a
6
+ * hundred aliases of the same expensive field, a fragment spread a thousand
7
+ * times, or a walk down a cycle the schema happens to contain. This module
8
+ * refuses those before a single resolver runs.
9
+ *
10
+ * Three bounds, all schema-independent, so they protect an application whose
11
+ * schema its author has never audited:
12
+ *
13
+ * - `maxAliases`: the universal one. Aliasing does not need a cycle or a deep
14
+ * schema, it works against anything, so this is the strict default.
15
+ * - `maxComplexity`: how many fields the query selects in total, fragments
16
+ * expanded, which is what a fragment bomb inflates.
17
+ * - `maxDepth`: nesting, which needs the schema to offer somewhere deep to
18
+ * go, so it is the loosest of the three.
19
+ *
20
+ * `maxTokens` bounds the document before it is even parsed, and is passed to
21
+ * graphql's own parser rather than implemented here.
22
+ */
23
+ const { GraphQLError, Kind } = require('graphql');
24
+
25
+ /**
26
+ * Defaults.
27
+ *
28
+ * Roomy enough that a page's own query never meets them (a handful of
29
+ * aliases, a dozen fields, four or five levels) and tight enough that
30
+ * amplification stops being free.
31
+ */
32
+ const DEFAULTS = Object.freeze({
33
+ maxAliases: 15,
34
+ maxComplexity: 1000,
35
+ maxDepth: 10,
36
+ maxTokens: 5000,
37
+ });
38
+
39
+ /**
40
+ * Normalizes `config.graphql`.
41
+ *
42
+ * A string stays what it always was: the endpoint. An object takes the
43
+ * endpoint, the limits and the access rules.
44
+ *
45
+ * @param {*} raw the configured value
46
+ * @param {string} fallback the default endpoint
47
+ * @returns {{endpoint: string, authenticated: boolean, roles: Array<string>, loopbackOnly: boolean, introspection: ?boolean, maxAliases: number, maxComplexity: number, maxDepth: number, maxTokens: number}} the settings
48
+ * @throws {TypeError} when it is neither a string nor an object
49
+ */
50
+ function graphqlConfig(raw, fallback) {
51
+ const settings = Object.assign({}, DEFAULTS, {
52
+ authenticated: false,
53
+ endpoint: fallback,
54
+ introspection: null,
55
+ loopbackOnly: false,
56
+ roles: [],
57
+ });
58
+
59
+ if (typeof raw === 'undefined' || raw === null) {
60
+ return settings;
61
+ }
62
+
63
+ if (typeof raw === 'string') {
64
+ settings.endpoint = raw;
65
+
66
+ return settings;
67
+ }
68
+
69
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
70
+ throw new TypeError(
71
+ 'config.graphql must be a string (the endpoint) or an object ({ endpoint, authenticated, roles, loopbackOnly, introspection, maxDepth, maxAliases, maxComplexity, maxTokens })'
72
+ );
73
+ }
74
+
75
+ if (typeof raw.endpoint === 'string' && raw.endpoint.length > 0) {
76
+ settings.endpoint = raw.endpoint;
77
+ }
78
+ if (typeof raw.introspection === 'boolean') {
79
+ settings.introspection = raw.introspection;
80
+ }
81
+ settings.authenticated = Boolean(raw.authenticated);
82
+ settings.loopbackOnly = Boolean(raw.loopbackOnly);
83
+ settings.roles = []
84
+ .concat(raw.roles || [])
85
+ .filter((role) => typeof role === 'string' && role.length > 0);
86
+
87
+ if (settings.roles.length > 0) {
88
+ settings.authenticated = true;
89
+ }
90
+
91
+ for (const key of ['maxAliases', 'maxComplexity', 'maxDepth', 'maxTokens']) {
92
+ if (raw[key] === false) {
93
+ settings[key] = Infinity;
94
+ } else if (Number.isInteger(raw[key]) && raw[key] > 0) {
95
+ settings[key] = raw[key];
96
+ }
97
+ }
98
+
99
+ return settings;
100
+ }
101
+
102
+ /**
103
+ * Measures one operation: its depth, how many aliases it uses and how many
104
+ * fields it selects in total, with fragments expanded.
105
+ *
106
+ * The walk stops as soon as a limit is passed, so a document built to blow
107
+ * up the analyzer cannot: the analyzer is bounded by the same numbers it
108
+ * enforces. Fragment cycles (which graphql's own `NoFragmentCycles` refuses
109
+ * separately) are cut by the visited set.
110
+ *
111
+ * @param {object} node an operation or fragment definition
112
+ * @param {Map<string, object>} fragments the document's fragments, by name
113
+ * @param {{maxAliases: number, maxComplexity: number, maxDepth: number}} limits the limits
114
+ * @returns {{aliases: number, complexity: number, depth: number}} the measurements
115
+ */
116
+ function measure(node, fragments, limits) {
117
+ const totals = { aliases: 0, complexity: 0, depth: 0 };
118
+
119
+ /**
120
+ * Whether one of the limits has been passed already
121
+ *
122
+ * @returns {boolean} stop or not
123
+ */
124
+ const done = () =>
125
+ totals.aliases > limits.maxAliases ||
126
+ totals.complexity > limits.maxComplexity ||
127
+ totals.depth > limits.maxDepth;
128
+
129
+ /**
130
+ * Walks a selection set
131
+ *
132
+ * @param {object} selectionSet the selection set
133
+ * @param {number} depth its depth
134
+ * @param {Set<string>} seen fragments already spread on this path
135
+ * @returns {void}
136
+ */
137
+ const walk = (selectionSet, depth, seen) => {
138
+ if (!selectionSet || done()) {
139
+ return;
140
+ }
141
+
142
+ totals.depth = Math.max(totals.depth, depth);
143
+
144
+ for (const selection of selectionSet.selections) {
145
+ if (done()) {
146
+ return;
147
+ }
148
+
149
+ if (selection.kind === Kind.FIELD) {
150
+ totals.complexity += 1;
151
+ if (selection.alias) {
152
+ totals.aliases += 1;
153
+ }
154
+ if (selection.selectionSet) {
155
+ walk(selection.selectionSet, depth + 1, seen);
156
+ }
157
+ continue;
158
+ }
159
+
160
+ if (selection.kind === Kind.INLINE_FRAGMENT) {
161
+ // An inline fragment is not a level of its own
162
+ walk(selection.selectionSet, depth, seen);
163
+ continue;
164
+ }
165
+
166
+ if (selection.kind === Kind.FRAGMENT_SPREAD) {
167
+ const name = selection.name.value;
168
+ const fragment = fragments.get(name);
169
+
170
+ if (!fragment || seen.has(name)) {
171
+ continue;
172
+ }
173
+
174
+ const nested = new Set(seen);
175
+
176
+ nested.add(name);
177
+ walk(fragment.selectionSet, depth, nested);
178
+ }
179
+ }
180
+ };
181
+
182
+ walk(node.selectionSet, 1, new Set());
183
+
184
+ return totals;
185
+ }
186
+
187
+ /**
188
+ * A graphql validation rule refusing queries past the limits.
189
+ *
190
+ * It runs before execution, so a refused query costs a parse and a walk and
191
+ * nothing else.
192
+ *
193
+ * @param {{maxAliases: number, maxComplexity: number, maxDepth: number}} limits the limits
194
+ * @returns {function} a graphql ValidationRule
195
+ */
196
+ function queryLimits(limits) {
197
+ return (context) => ({
198
+ /**
199
+ * @param {object} node an OperationDefinition
200
+ * @returns {false} never descend: the walk below did it already
201
+ */
202
+ OperationDefinition(node) {
203
+ const fragments = new Map();
204
+
205
+ for (const definition of context.getDocument().definitions) {
206
+ if (definition.kind === Kind.FRAGMENT_DEFINITION) {
207
+ fragments.set(definition.name.value, definition);
208
+ }
209
+ }
210
+
211
+ const { aliases, complexity, depth } = measure(node, fragments, limits);
212
+
213
+ if (depth > limits.maxDepth) {
214
+ context.reportError(
215
+ new GraphQLError(
216
+ `Query is too deep: ${depth} levels, the limit is ${limits.maxDepth}`,
217
+ { extensions: { code: 'GRAPHQL_VALIDATION_FAILED' }, nodes: [node] }
218
+ )
219
+ );
220
+ }
221
+
222
+ if (aliases > limits.maxAliases) {
223
+ context.reportError(
224
+ new GraphQLError(
225
+ `Query uses too many aliases: more than ${limits.maxAliases}`,
226
+ { extensions: { code: 'GRAPHQL_VALIDATION_FAILED' }, nodes: [node] }
227
+ )
228
+ );
229
+ }
230
+
231
+ if (complexity > limits.maxComplexity) {
232
+ context.reportError(
233
+ new GraphQLError(
234
+ `Query is too complex: it selects more than ${limits.maxComplexity} fields`,
235
+ { extensions: { code: 'GRAPHQL_VALIDATION_FAILED' }, nodes: [node] }
236
+ )
237
+ );
238
+ }
239
+
240
+ return false;
241
+ },
242
+ });
243
+ }
244
+
245
+ /**
246
+ * An Apollo plugin that stops resolving once the client is gone.
247
+ *
248
+ * graphql-js 16 cannot cancel an execution that has started, so this is done
249
+ * where it can be: every field asks first, and a query whose client
250
+ * disconnected or whose request already timed out (`base/timeout.js` answered
251
+ * a 503) stops at the next field instead of running to completion against
252
+ * nobody.
253
+ *
254
+ * @returns {object} an ApolloServerPlugin
255
+ */
256
+ function cancellation() {
257
+ return {
258
+ /**
259
+ * @param {object} params the request
260
+ * @param {object} params.contextValue henri's context (`{ req, res }`)
261
+ * @returns {Promise<object>} the request listener
262
+ */
263
+ async requestDidStart({ contextValue }) {
264
+ const req = contextValue && contextValue.req;
265
+ const res = contextValue && contextValue.res;
266
+ let disconnected = false;
267
+
268
+ if (res && typeof res.once === 'function') {
269
+ res.once('close', () => {
270
+ disconnected = !res.writableEnded;
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Whether nobody is waiting for this answer any more: the client hung
276
+ * up, or `base/timeout.js` already answered a 503
277
+ *
278
+ * @returns {boolean} gone or not
279
+ */
280
+ const gone = () =>
281
+ disconnected ||
282
+ Boolean(req && req.timedout) ||
283
+ Boolean(req && req.socket && req.socket.destroyed);
284
+
285
+ return {
286
+ /**
287
+ * @returns {Promise<object>} the execution listener
288
+ */
289
+ async executionDidStart() {
290
+ return {
291
+ /**
292
+ * @returns {void}
293
+ * @throws {GraphQLError} when the client is gone
294
+ */
295
+ willResolveField() {
296
+ if (gone()) {
297
+ throw new GraphQLError('Request cancelled', {
298
+ extensions: { code: 'REQUEST_CANCELLED' },
299
+ });
300
+ }
301
+ },
302
+ };
303
+ },
304
+ };
305
+ },
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Whether a user owns every one of the given roles.
311
+ *
312
+ * Deliberately a local copy rather than an import from core: this module is
313
+ * meant to travel with the GraphQL layer wherever it lives.
314
+ *
315
+ * @param {object} user a user instance
316
+ * @param {Array<string>} roles the roles it must have
317
+ * @returns {Promise<boolean>} allowed or not
318
+ */
319
+ async function hasRoles(user, roles) {
320
+ if (!user) {
321
+ return false;
322
+ }
323
+
324
+ if (typeof user.hasRole === 'function') {
325
+ return Boolean(await user.hasRole(roles));
326
+ }
327
+
328
+ const owned = Array.isArray(user.roles)
329
+ ? user.roles
330
+ : [user.roles].filter(Boolean);
331
+
332
+ return roles.every((role) => owned.includes(role));
333
+ }
334
+
335
+ /**
336
+ * The middleware guarding the endpoint: loopback only, signed in, or holding
337
+ * the roles the application asked for.
338
+ *
339
+ * Answers 404 off the loopback interface (the endpoint should not even look
340
+ * like it exists) and 401/403 otherwise.
341
+ *
342
+ * @param {object} settings the settings built by graphqlConfig()
343
+ * @returns {function} middleware
344
+ */
345
+ /**
346
+ * Refuses the request: through `res.boom` when core's middleware is in front
347
+ * of this one, and with the body it would have sent when it is not
348
+ *
349
+ * The guard used to reach for `res.boom` unguarded. Inside a henri boot that
350
+ * is always there, but this package is no longer the same package as the
351
+ * middleware that puts it there, and nothing declares the dependency. The
352
+ * fallback is byte-identical to `base/boom.js` (same status, same body, same
353
+ * key order), so it only ever fires where the alternative was a TypeError.
354
+ * `base/csrf.js` in core does the same thing for the same reason.
355
+ *
356
+ * @param {object} res the response
357
+ * @param {number} statusCode 401 or 403
358
+ * @param {string} error the status text
359
+ * @param {string} message why
360
+ * @returns {*} the response
361
+ */
362
+ function refuse(res, statusCode, error, message) {
363
+ const method = statusCode === 401 ? 'unauthorized' : 'forbidden';
364
+
365
+ if (res.boom && typeof res.boom[method] === 'function') {
366
+ return res.boom[method](message);
367
+ }
368
+
369
+ return res.status(statusCode).json({ error, message, statusCode });
370
+ }
371
+
372
+ function accessGuard(settings) {
373
+ const { authenticated, roles } = settings;
374
+
375
+ return async (req, res, next) => {
376
+ if (!authenticated && roles.length === 0) {
377
+ return next();
378
+ }
379
+
380
+ const signedIn =
381
+ typeof req.isAuthenticated === 'function'
382
+ ? req.isAuthenticated()
383
+ : Boolean(req.user);
384
+
385
+ if (!signedIn || !req.user) {
386
+ return refuse(res, 401, 'Unauthorized', 'Authentication required');
387
+ }
388
+
389
+ if (roles.length > 0 && !(await hasRoles(req.user, roles))) {
390
+ return refuse(res, 403, 'Forbidden', 'Insufficient roles');
391
+ }
392
+
393
+ return next();
394
+ };
395
+ }
396
+
397
+ module.exports = {
398
+ DEFAULTS,
399
+ accessGuard,
400
+ cancellation,
401
+ graphqlConfig,
402
+ measure,
403
+ queryLimits,
404
+ };
package/src/graphql.js ADDED
@@ -0,0 +1,377 @@
1
+ const BaseModule = require('@usehenri/core/module');
2
+ const { mergeTypeDefs, mergeResolvers } = require('@graphql-tools/merge');
3
+ const { makeExecutableSchema } = require('@graphql-tools/schema');
4
+ const { ApolloServer } = require('@apollo/server');
5
+ const { expressMiddleware } = require('@as-integrations/express5');
6
+ const { GraphQLError } = require('graphql');
7
+ const debug = require('debug')('henri:graphql');
8
+
9
+ const {
10
+ accessGuard,
11
+ cancellation,
12
+ graphqlConfig,
13
+ queryLimits,
14
+ } = require('./graphql-guard');
15
+ const { loopbackOnly } = require('./loopback');
16
+
17
+ /**
18
+ * Build a GraphQLError subclass carrying an `extensions.code`
19
+ *
20
+ * @param {string} code the extension code (ex: UNAUTHENTICATED)
21
+ * @returns {typeof GraphQLError} the error class
22
+ */
23
+ const errorWithCode = (code) =>
24
+ class extends GraphQLError {
25
+ /**
26
+ * @param {string} message error message
27
+ * @param {object} [options={}] GraphQLError options
28
+ */
29
+ constructor(message, options = {}) {
30
+ super(message, {
31
+ ...options,
32
+ extensions: { code, ...(options.extensions || {}) },
33
+ });
34
+ }
35
+ };
36
+
37
+ const AuthenticationError = errorWithCode('UNAUTHENTICATED');
38
+ const ForbiddenError = errorWithCode('FORBIDDEN');
39
+ const UserInputError = errorWithCode('BAD_USER_INPUT');
40
+ const ValidationError = errorWithCode('GRAPHQL_VALIDATION_FAILED');
41
+ const SyntaxError = errorWithCode('GRAPHQL_PARSE_FAILED');
42
+
43
+ /**
44
+ * The GraphQL module of henri
45
+ *
46
+ * This package ships it (`"henri": { "module": "./module.js" }` in its
47
+ * package.json), so an application that depends on the package has it in the
48
+ * boot as `henri.graphql`, with nothing else to write. An application that
49
+ * does not never loads Apollo Server, and `henri.graphql` is undefined.
50
+ *
51
+ * It only needs the configuration: the schema is built from the models,
52
+ * which extract into it at their own level.
53
+ *
54
+ * @class Graphql
55
+ * @extends {BaseModule}
56
+ */
57
+ class Graphql extends BaseModule {
58
+ /**
59
+ * Creates an instance of Graphql.
60
+ *
61
+ * @param {object} [henri=null] A henri instance
62
+ * @memberof Graphql
63
+ */
64
+ constructor(henri = null) {
65
+ super();
66
+
67
+ this.reloadable = true;
68
+ this.needs = ['config'];
69
+ this.runlevel = 1;
70
+ this.name = 'graphql';
71
+ this.henri = henri;
72
+
73
+ this.typesList = [];
74
+ this.resolversList = [];
75
+
76
+ this.types = null;
77
+ this.resolvers = null;
78
+ this.schema = null;
79
+ this.endpoint = '/_henri/gql';
80
+ /** Normalized `config.graphql`: the endpoint, the limits, the access rules */
81
+ this.settings = graphqlConfig(null, this.endpoint);
82
+ this.active = false;
83
+
84
+ this.graphqlServer = null;
85
+ this.ready = null;
86
+
87
+ this._handler = null;
88
+ this._middlewareRegistered = false;
89
+
90
+ this.init = this.init.bind(this);
91
+ this.extract = this.extract.bind(this);
92
+ this.merge = this.merge.bind(this);
93
+ this.run = this.run.bind(this);
94
+ this.reload = this.reload.bind(this);
95
+ this.createServer = this.createServer.bind(this);
96
+
97
+ this.GraphQLError = GraphQLError;
98
+ this.ApolloError = GraphQLError;
99
+ this.toApolloError = (error, code = 'INTERNAL_SERVER_ERROR') =>
100
+ error instanceof GraphQLError
101
+ ? error
102
+ : new GraphQLError(error.message, {
103
+ extensions: { code },
104
+ originalError: error,
105
+ });
106
+ this.SyntaxError = SyntaxError;
107
+ this.ValidationError = ValidationError;
108
+ this.AuthenticationError = AuthenticationError;
109
+ this.ForbiddenError = ForbiddenError;
110
+ this.UserInputError = UserInputError;
111
+ }
112
+
113
+ /**
114
+ * Module initialization
115
+ * Called after being loaded by Modules
116
+ *
117
+ * @async
118
+ * @returns {!string} The name of the module
119
+ * @memberof Graphql
120
+ */
121
+ async init() {
122
+ const { config } = this.henri;
123
+
124
+ this.settings = graphqlConfig(
125
+ config.has('graphql') ? config.get('graphql') : null,
126
+ this.endpoint
127
+ );
128
+ this.endpoint = this.settings.endpoint;
129
+
130
+ if (this.schema !== null && !this.graphqlServer) {
131
+ await this.createServer();
132
+ }
133
+
134
+ if (!this.henri.isProduction && this.active) {
135
+ this.henri.pen.info('graphql', 'endpoint', this.endpoint);
136
+ }
137
+
138
+ return this.name;
139
+ }
140
+
141
+ /**
142
+ * Create (and start) the Apollo server for the current schema
143
+ * The express middleware is registered once and always delegates to the
144
+ * latest server instance, so reloads do not stack handlers.
145
+ *
146
+ * @returns {Promise<void>} resolves once the server has started
147
+ * @memberof Graphql
148
+ */
149
+ createServer() {
150
+ const { introspection, maxTokens } = this.settings;
151
+ const server = new ApolloServer({
152
+ // Apollo's own simple-request protection: a POST must be
153
+ // `application/json` or carry `apollo-require-preflight`, so a form on
154
+ // another site cannot reach the endpoint. On by default; named here so
155
+ // it stays that way.
156
+ csrfPrevention: true,
157
+ introspection:
158
+ introspection === null ? !this.henri.isProduction : introspection,
159
+ parseOptions: Number.isFinite(maxTokens) ? { maxTokens } : {},
160
+ plugins: [cancellation()],
161
+ schema: this.schema,
162
+ validationRules: [queryLimits(this.settings)],
163
+ });
164
+
165
+ this.graphqlServer = server;
166
+ this.ready = server
167
+ .start()
168
+ .then(() => {
169
+ this._handler = expressMiddleware(server, {
170
+ context: async ({ req, res }) => ({ req, res }),
171
+ });
172
+ debug('apollo server started');
173
+ })
174
+ .catch((error) => {
175
+ this.henri.pen.error('graphql', 'unable to start apollo server');
176
+ this.henri.pen.error('graphql', error);
177
+ this.active = false;
178
+ });
179
+
180
+ if (!this._middlewareRegistered) {
181
+ this._middlewareRegistered = true;
182
+ this.henri.addMiddleware('graphql', (app) => {
183
+ const guards = [];
184
+
185
+ if (this.settings.loopbackOnly) {
186
+ guards.push(loopbackOnly(this.henri));
187
+ }
188
+
189
+ guards.push(accessGuard(this.settings));
190
+
191
+ app.use(this.endpoint, ...guards, (req, res, next) => {
192
+ if (!this.active || !this._handler) {
193
+ return next();
194
+ }
195
+
196
+ return this._handler(req, res, next);
197
+ });
198
+ });
199
+ }
200
+
201
+ return this.ready;
202
+ }
203
+
204
+ /**
205
+ * Extract graphql items from a model (if any)
206
+ *
207
+ * @param {object} model a model
208
+ * @returns {boolean} status
209
+ * @memberof Graphql
210
+ */
211
+ extract(model) {
212
+ if (typeof model.graphql === 'undefined') {
213
+ return false;
214
+ }
215
+
216
+ const { types = null, resolvers = {} } = model.graphql;
217
+
218
+ if (typeof types === 'string') {
219
+ this.typesList.push(types);
220
+ }
221
+
222
+ if (typeof resolvers === 'object') {
223
+ this.resolversList.push(resolvers);
224
+ }
225
+
226
+ return true;
227
+ }
228
+
229
+ /**
230
+ * Merge the graphql types and resolver
231
+ * After extracting from all the models, we merge and compile them
232
+ *
233
+ * @return {boolean} success?
234
+ * @memberof Graphql
235
+ */
236
+ merge() {
237
+ let should = false;
238
+
239
+ if (this.typesList.length > 0) {
240
+ should = true;
241
+ this.types = mergeTypeDefs(this.typesList);
242
+ }
243
+
244
+ if (this.resolversList.length > 0) {
245
+ should = true;
246
+ this.resolvers = mergeResolvers(this.resolversList);
247
+ }
248
+
249
+ if (should) {
250
+ this.active = true;
251
+ try {
252
+ this.schema = makeExecutableSchema({
253
+ resolvers: this.resolvers || {},
254
+ typeDefs: this.types,
255
+ });
256
+ this.henri.pen.info('graphql', 'schema', 'valid');
257
+ this.createServer();
258
+ } catch (error) {
259
+ this.henri.pen.error('graphql', error);
260
+ this.henri.pen.error(
261
+ 'graphql',
262
+ `THE GRAPHQL SERVICE WON'T BE AVAILABLE`
263
+ );
264
+ this.henri.pen.error('graphql', `UNTIL YOU FIX THIS ERROR`);
265
+ this.active = false;
266
+ }
267
+ } else {
268
+ this.active = false;
269
+
270
+ return false;
271
+ }
272
+
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Run a Graphql query against compiled graphql
278
+ *
279
+ * @async
280
+ * @param {Graphql} [query=`{ No query }`] the graphql query
281
+ * @param {object} [variables] query variables
282
+ * @param {object} [contextValue={}] the resolvers' context (ex: { req, res })
283
+ * @returns {(Promise<{data: object, errors: Array}> | "No graphql schema found.")} value
284
+ * @memberof Graphql
285
+ */
286
+ async run(query = `{ No query }`, variables = undefined, contextValue = {}) {
287
+ if (!this.schema || !this.graphqlServer) {
288
+ return 'No graphql schema found.';
289
+ }
290
+
291
+ await this.ready;
292
+
293
+ const response = await this.graphqlServer.executeOperation(
294
+ {
295
+ query,
296
+ variables,
297
+ },
298
+ { contextValue: contextValue || {} }
299
+ );
300
+
301
+ if (response.body.kind === 'single') {
302
+ const { data, errors } = response.body.singleResult;
303
+
304
+ return { data, errors };
305
+ }
306
+
307
+ return {
308
+ data: null,
309
+ errors: [new GraphQLError('incremental delivery is not supported')],
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Reloads the module
315
+ * State is cleared synchronously; the previous Apollo server is stopped in
316
+ * the background.
317
+ *
318
+ * @async
319
+ * @returns {string} Module name
320
+ * @memberof Graphql
321
+ */
322
+ async reload() {
323
+ const previous = this.graphqlServer;
324
+
325
+ this.typesList = [];
326
+ this.resolversList = [];
327
+
328
+ this.active = false;
329
+ this.types = null;
330
+ this.resolvers = null;
331
+ this.schema = null;
332
+ this.graphqlServer = null;
333
+ this.ready = null;
334
+ this._handler = null;
335
+
336
+ if (previous) {
337
+ try {
338
+ await previous.stop();
339
+ } catch (error) {
340
+ // The previous server is gone either way; the new one is what matters
341
+ this.henri.pen.warn(
342
+ 'graphql',
343
+ 'unable to stop the previous apollo server',
344
+ error.message
345
+ );
346
+ debug('error while stopping previous apollo server %O', error);
347
+ }
348
+ }
349
+
350
+ return this.name;
351
+ }
352
+
353
+ /**
354
+ * Stops the module
355
+ *
356
+ * @async
357
+ * @returns {(string|boolean)} Module name or false
358
+ * @memberof Graphql
359
+ */
360
+ async stop() {
361
+ if (this.graphqlServer) {
362
+ const server = this.graphqlServer;
363
+
364
+ this.graphqlServer = null;
365
+ this._handler = null;
366
+ this.ready = null;
367
+
368
+ await server.stop();
369
+
370
+ return this.name;
371
+ }
372
+
373
+ return false;
374
+ }
375
+ }
376
+
377
+ module.exports = Graphql;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The `loopbackOnly: true` option of `config.graphql`.
3
+ *
4
+ * Core has one of these in `base/http.js`, but this package reaches into
5
+ * core for exactly one thing -- `BaseModule`, which the module system
6
+ * documents -- and that is worth keeping to one. The predicate itself is not
7
+ * copied: `isLoopback` is public API on the instance
8
+ * (`henri.utils.isLoopback`, see the API reference), so `::1`,
9
+ * `::ffff:127.0.0.1` and the whole `127.0.0.0/8` block are handled by core's
10
+ * definition and cannot drift from it.
11
+ *
12
+ * Core's version content-negotiates its 404. A GraphQL endpoint answers JSON
13
+ * either way, so this one always does.
14
+ *
15
+ * @param {object} henri A henri instance
16
+ * @returns {function} express middleware
17
+ */
18
+ const loopbackOnly = (henri) => (req, res, next) => {
19
+ const address = req.socket && req.socket.remoteAddress;
20
+
21
+ if (henri.utils.isLoopback(address)) {
22
+ return next();
23
+ }
24
+
25
+ return res.status(404).json({
26
+ error: 'Not Found',
27
+ message: 'Not Found',
28
+ statusCode: 404,
29
+ });
30
+ };
31
+
32
+ module.exports = { loopbackOnly };