@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 +66 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +18 -0
- package/module.js +8 -0
- package/package.json +51 -10
- package/src/graphql-guard.js +404 -0
- package/src/graphql.js +377 -0
- package/src/loopback.js +32 -0
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
|
-
|
|
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
|
-
"
|
|
7
|
-
|
|
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
|
-
"
|
|
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;
|
package/src/loopback.js
ADDED
|
@@ -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 };
|