graphql-armory 0.1.0.pre3 → 0.1.0.pre4
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +5 -5
- data/docs/persisted-queries.md +184 -0
- data/lib/graphql/armory/persisted_queries/manifest.rb +109 -0
- data/lib/graphql/armory/persisted_queries.rb +144 -0
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f463135c3578c6248c02f5610cb6bd1bc3f88fe9c26867e8cbd5164c84be408
|
|
4
|
+
data.tar.gz: 0344af8ea82c901fb5ad36cd1d236039f30afe3969142305b3e9b48d0890e5ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 171b49d43c89c1c306949b7e30c56aa71687ccff9f3028f99e8b5f181f4a80b650d8a58e996090013bfa8e800632848109e557842f8ee78aecffbf0893d8d14a
|
|
7
|
+
data.tar.gz: 477b8904c178b41c690f2c308004f0d0a8f441d4df144e4ff2cb4e1a06babe6a33c401728edfcc7ebfd0a14c8ae336c8d7ce12bf4de0944b11e2171cdc02c0c8
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.0.pre4 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
Persisted queries / operation allowlist (roadmap module 3). Docs: `docs/persisted-queries.md`.
|
|
8
|
+
|
|
9
|
+
- Persisted queries, part 1: `GraphQL::Armory::PersistedQueries::Manifest` loads Apollo's persisted query manifest or Relay's persisted output, checks every id is the digest of its query (sha256, md5 or sha1) and parses each query once; any problem raises at boot.
|
|
10
|
+
- Persisted queries, part 2: `use GraphQL::Armory::PersistedQueries, store:, strict:` runs the stored operation a request names by id (`context[:operation_id]`; `PersistedQueries.operation_id(params)` reads it from `operationId`, `extensions.operationId` or `extensions.persistedQuery.sha256Hash`). Unknown ids and mismatched queries are refused; strict mode refuses raw queries unless the context holds `armory_allow_raw_query: true`. Docs: `docs/persisted-queries.md`.
|
|
11
|
+
|
|
5
12
|
## 0.1.0.pre3 (2026-10-29)
|
|
6
13
|
|
|
7
14
|
Encrypted, versioned IDs and cursors (roadmap module 2), and a docs page per module.
|
data/README.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
Open-source extensions for [graphql-ruby](https://github.com/rmosolgo/graphql-ruby), built on its public extension points: no fork, no monkey patching. One gem, one module at a time; each module has its own page in [`docs/`](docs) with the problem it solves, how to use it, and its limits.
|
|
8
8
|
|
|
9
|
-
> **Status: early development.** Prereleases on RubyGems (`0.1.0.
|
|
9
|
+
> **Status: early development.** Prereleases on RubyGems (`0.1.0.pre4`). The API may change before 1.0.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
|
-
gem "graphql-armory", "~> 0.1.0.
|
|
14
|
+
gem "graphql-armory", "~> 0.1.0.pre4" # a prerelease: `gem install graphql-armory --pre`
|
|
15
15
|
gem "pundit" # or "cancancan", for the authorization module
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -24,8 +24,8 @@ Status: ✅ released, 🚧 in progress, ⬜ not started. Update this table in th
|
|
|
24
24
|
| Order | Module | Status | Docs | Current limits |
|
|
25
25
|
|---|---|---|---|---|
|
|
26
26
|
| 1 | Authorization (Pundit, CanCanCan) | ✅ | [docs/authorization.md](docs/authorization.md) | Released in 0.1.0.pre2 (prerelease). Pundit and CanCanCan (the audit checks less with CanCanCan). Global IDs in `loads:` decoded through `object_from_id`; `loads:` inside input objects scoped with `include Base`. `Execution::Next` needs graphql >= 2.6.2. |
|
|
27
|
-
| 2 | Encrypted, versioned IDs and cursors | ✅ | [docs/encrypted-ids.md](docs/encrypted-ids.md) | Released in 0.1.0.
|
|
28
|
-
| 3 | Persisted queries / operation
|
|
27
|
+
| 2 | Encrypted, versioned IDs and cursors | ✅ | [docs/encrypted-ids.md](docs/encrypted-ids.md) | Released in 0.1.0.pre4 (prerelease). `Encoder` (AES-256-GCM), versioned chains and `Legacy`, `Encoder.default` (ENV or Rails), `use GraphQL::Armory::IDs`. Composite primary keys not supported. |
|
|
28
|
+
| 3 | Persisted queries / operation allowlist | ✅ | [docs/persisted-queries.md](docs/persisted-queries.md) | Released in 0.1.0.pre4 (prerelease). Allowlist from a build manifest (Apollo, Relay), `use GraphQL::Armory::PersistedQueries` with strict mode. A runtime store (database, sync endpoint) is planned for later. |
|
|
29
29
|
| 4 | Rate limiting | ⬜ | | Research not started. |
|
|
30
30
|
| 5 | Stable (keyset) connections | ⬜ | | Research not started. |
|
|
31
31
|
| 6 | Subscriptions over Pusher / Ably | ⬜ | | Research not started. |
|
|
@@ -53,7 +53,7 @@ GRAPHQL_VERSION="~> 2.4.0" docker compose run --rm test # another graphq
|
|
|
53
53
|
Releases are published from GitHub Actions with RubyGems Trusted Publishing (`.github/workflows/release.yml`): no API key is stored and no OTP is asked. Bump `GraphQL::Armory::VERSION` and the CHANGELOG on `main`, then push the matching tag:
|
|
54
54
|
|
|
55
55
|
```
|
|
56
|
-
git tag v0.1.0.
|
|
56
|
+
git tag v0.1.0.pre4 && git push origin v0.1.0.pre4
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
The workflow checks that the tag matches the version, runs the specs and pushes the gem. It only publishes a tag on a commit of `main`. The `release` environment can require a maintainer's approval first (on a public repository, or a private one on a paid GitHub plan).
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Persisted queries
|
|
2
|
+
|
|
3
|
+
Your API runs the operations your clients were built with, and nothing else.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
A GraphQL endpoint accepts any query anyone writes. Authorization decides what each user may read, but the endpoint still:
|
|
8
|
+
|
|
9
|
+
- **Runs queries you never wrote.** Anyone with a token can send deep, wide or expensive queries, or explore the schema field by field to find something you forgot to protect.
|
|
10
|
+
- **Parses and validates every request.** A large query costs CPU before a single field resolves.
|
|
11
|
+
- **Sends the full query text on every request.** Mobile clients pay that in bandwidth.
|
|
12
|
+
|
|
13
|
+
## The solution
|
|
14
|
+
|
|
15
|
+
Your client's build step (Apollo's persisted query manifest generator, or the Relay compiler's `persistConfig`) writes every operation the app uses to a JSON file. The server loads that file and clients send the operation's id instead of its text:
|
|
16
|
+
|
|
17
|
+
- **An allowlist.** In strict mode the server refuses any query that is not in the file, before validating it, so a raw query cannot even probe field names.
|
|
18
|
+
- **Tamper-evident.** Every id must be the digest (SHA-256 by default) of its query. A manifest edited by hand to put another query behind an id is refused when the app boots.
|
|
19
|
+
- **Parsed once.** Each stored query is parsed at boot and handed to graphql-ruby as a document, so a request skips parsing.
|
|
20
|
+
- **Smaller requests.** The client sends a 64-character id and the variables.
|
|
21
|
+
- **Both engines.** It hooks only `Schema.query_class`, so it works on the interpreter and on `Execution::Next`.
|
|
22
|
+
|
|
23
|
+
## Who does what
|
|
24
|
+
|
|
25
|
+
Two separate pieces work together. Your client tooling writes the list; this gem enforces it on the server.
|
|
26
|
+
|
|
27
|
+
| Piece | Who provides it | What it does |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `persisted-queries.json` | Apollo or Relay, in your client's build | Lists every operation the app uses. You do not write or edit it by hand. |
|
|
30
|
+
| `PersistedQueries::Manifest` | this gem | Loads the file at boot and checks that nobody edited it. |
|
|
31
|
+
| `use GraphQL::Armory::PersistedQueries` | this gem | Runs the operation a request names by id, and in strict mode refuses everything else. |
|
|
32
|
+
|
|
33
|
+
Think of an event: the client tooling prints the guest list, and this gem is the guard at the door. A list without a guard stops nobody. Apollo's own router (GraphOS Router, Enterprise plan) can enforce the list; Apollo Server's built-in `persistedQueries` option is only a cache (automatic persisted queries) and still runs any query. Open-source graphql-ruby has no equivalent (GraphQL-Pro has its OperationStore), so without this gem a Ruby API ignores the file and keeps running any query it receives.
|
|
34
|
+
|
|
35
|
+
Your part is to copy the file the client build produced into the server (for example as `persisted-queries.json` in the Rails root) and deploy it.
|
|
36
|
+
|
|
37
|
+
### What the file looks like
|
|
38
|
+
|
|
39
|
+
Apollo's manifest, written by `generate-persisted-query-manifest` (`@apollo/generate-persisted-query-manifest`, see [Apollo's guide](https://www.apollographql.com/docs/react/data/persisted-queries)):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"format": "apollo-persisted-query-manifest",
|
|
44
|
+
"version": 1,
|
|
45
|
+
"operations": [
|
|
46
|
+
{
|
|
47
|
+
"id": "6c5d8a12c92cffe7786b5a6d288f56d479973fd0f35b31bce215a4df9cf554ef",
|
|
48
|
+
"name": "Invoices",
|
|
49
|
+
"type": "query",
|
|
50
|
+
"body": "query Invoices { invoices { number total } }"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Relay's persisted output, written by `relay-compiler` with `persistConfig` (see [Relay's guide](https://relay.dev/docs/guides/persisted-queries/)), is a flat map of id to query:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"904cd33a4439518767fd75402dd7e47b": "query Invoices { invoices { number total } }"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each id is the digest of its exact query text: SHA-256 in Apollo's manifest, MD5 in this Relay example (load it with `digest: :md5`). Change a query without regenerating its id and the app refuses to boot, naming that id.
|
|
65
|
+
|
|
66
|
+
A client then sends only the id and the variables:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "operationId": "6c5d8a12c92cffe7786b5a6d288f56d479973fd0f35b31bce215a4df9cf554ef", "variables": {} }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How to use it
|
|
73
|
+
|
|
74
|
+
Load the manifest and add the plugin:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class AppSchema < GraphQL::Schema
|
|
78
|
+
use GraphQL::Armory::PersistedQueries,
|
|
79
|
+
store: GraphQL::Armory::PersistedQueries::Manifest.load(Rails.root.join("persisted-queries.json")),
|
|
80
|
+
strict: true
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Pass the operation id from the request in the context:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class GraphqlController < ApplicationController
|
|
88
|
+
def execute
|
|
89
|
+
result = AppSchema.execute(
|
|
90
|
+
params[:query],
|
|
91
|
+
variables: params[:variables],
|
|
92
|
+
operation_name: params[:operationName],
|
|
93
|
+
context: {
|
|
94
|
+
current_user: current_user,
|
|
95
|
+
operation_id: GraphQL::Armory::PersistedQueries.operation_id(params),
|
|
96
|
+
armory_allow_raw_query: current_user&.staff? || Rails.env.development?
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
render json: result
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`PersistedQueries.operation_id(params)` reads the id where clients send it: `operationId`, `extensions.operationId`, or Apollo's `extensions.persistedQuery.sha256Hash` (`extensions` may be a JSON string, as in GET requests). The first one present wins, in that order. It answers nil when there is no id, or when it is not a non-empty String. Params must have String keys, as Rack and Rails params do. An empty `query` sent with an id (a GET request with `query=`) counts as no query.
|
|
105
|
+
|
|
106
|
+
What each request does:
|
|
107
|
+
|
|
108
|
+
| Request | Result |
|
|
109
|
+
|---|---|
|
|
110
|
+
| An id the manifest holds | Runs the stored operation with the request's variables and operation name. |
|
|
111
|
+
| An id plus the same query (Apollo's clients may send both) | Runs the stored operation. |
|
|
112
|
+
| An id plus a different query | Refused: `The operation id and the query do not match`. Neither runs. |
|
|
113
|
+
| An id the manifest does not hold | Refused: `Unknown operation id`. |
|
|
114
|
+
| A raw query, strict mode off | Runs as usual. Use this while clients migrate. |
|
|
115
|
+
| A raw query, strict mode on | Refused: `Only persisted operations are allowed: send an operation id`, unless the context holds `armory_allow_raw_query: true`. |
|
|
116
|
+
|
|
117
|
+
A refused request fails validation with that message as its only error, so nothing resolves, even when the app executes with `validate: false`.
|
|
118
|
+
|
|
119
|
+
### Relay
|
|
120
|
+
|
|
121
|
+
Relay's persisted output is a flat map of id to query text. Tell the manifest which digest Relay was configured with:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
GraphQL::Armory::PersistedQueries::Manifest.load("persisted-queries.json", digest: :md5)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`digest: false` accepts ids that are not digests (names such as `invoices-v1`). Then nothing proves an id belongs to its query: anyone who can edit the file can change what an id runs.
|
|
128
|
+
|
|
129
|
+
Relay's example network layer sends the id as `doc_id`. Pass it yourself: `operation_id: params[:doc_id]`.
|
|
130
|
+
|
|
131
|
+
### Rolling it out
|
|
132
|
+
|
|
133
|
+
1. Generate the manifest in the client build and ship it with the server.
|
|
134
|
+
2. Add the plugin with `strict: false`. Clients that send ids get stored operations; everything else keeps working.
|
|
135
|
+
3. When every client in use sends ids, turn on `strict: true`.
|
|
136
|
+
|
|
137
|
+
Deploy the server with the new manifest before, or with, the clients that use its new operations: until then they get `Unknown operation id`. Keep old operations in the file while old app versions are still in use.
|
|
138
|
+
|
|
139
|
+
## Reference
|
|
140
|
+
|
|
141
|
+
`Manifest.new(data, digest: :sha256)`, `Manifest.load(path, digest: :sha256)`
|
|
142
|
+
|
|
143
|
+
- Reads Apollo's persisted query manifest (`format: "apollo-persisted-query-manifest"`, `version: 1`) or Relay's flat map.
|
|
144
|
+
- `digest:` is `:sha256`, `:sha1`, `:md5` or `false`.
|
|
145
|
+
- `fetch(id)` answers the operation (`id`, `name`, `body`, `document`, all frozen) or nil. `size` counts them.
|
|
146
|
+
- Raises `PersistedQueries::ConfigurationError` at boot for: invalid JSON, an unreadable file, an unknown format or version, an id that is not the digest of its query, a query that does not parse, a duplicate id, an entry that is not an object, or an empty manifest. Messages never quote the file's queries.
|
|
147
|
+
|
|
148
|
+
`use GraphQL::Armory::PersistedQueries, store:, strict: false`
|
|
149
|
+
|
|
150
|
+
- `store:` answers `fetch(id)` with an object that has `body` and `document`, or nil. A Manifest, or your own store.
|
|
151
|
+
- `strict:` is `true` or `false`.
|
|
152
|
+
- Works with your own `query_class`, whether it is set before `use`, after it, or in a child schema: the plugin subclasses it.
|
|
153
|
+
- `use` it once per schema hierarchy. A second `use`, on the same schema or a child, raises instead of replacing the first one's settings.
|
|
154
|
+
- A store whose `fetch` raises or answers something without `document` makes the request raise: it fails closed, as an exception.
|
|
155
|
+
|
|
156
|
+
Context keys: `operation_id` (a String; anything else is an unknown id), `armory_allow_raw_query` (only `true` allows).
|
|
157
|
+
|
|
158
|
+
## Guarantees and limits
|
|
159
|
+
|
|
160
|
+
- A request with an id runs only the stored document for that id, or nothing.
|
|
161
|
+
- In strict mode a raw query runs only when the app's context says `armory_allow_raw_query: true`. The plugin does not decide who is staff: your controller does.
|
|
162
|
+
- Queries built by your own code as `GraphQL::Query` objects (for example in `multiplex`) bypass the plugin's lookup. In strict mode they are not refused either: the check happens when the plugin's query class builds the query.
|
|
163
|
+
- In strict mode, schema introspection run through the plugin is refused too. `Schema.to_json` and `rake graphql:schema:json` need `context: { armory_allow_raw_query: true }` (the rake task's `load_context`). `Schema.to_definition` and `Schema.validate` are not affected.
|
|
164
|
+
- The manifest lives in the server's code: adding an operation needs a deploy. See the runtime store below.
|
|
165
|
+
- Subscriptions: graphql-ruby re-runs a subscription with its printed query, which the plugin accepts when it matches the stored document. Subscriptions are not tested with persisted queries yet.
|
|
166
|
+
|
|
167
|
+
## Planned: a runtime store
|
|
168
|
+
|
|
169
|
+
The manifest is the simplest store: one file, no database, deployed with the code. Teams that ship clients independently of the server (mobile apps, several frontends) need operations published without a server deploy. That is planned for later, behind the same `store:` interface:
|
|
170
|
+
|
|
171
|
+
- **A database table** (ActiveRecord) holding operations per client, with the digest checked on write.
|
|
172
|
+
- **A sync endpoint** the client build calls to publish its operations, authenticated per client with an HMAC of the request body and a secret per client.
|
|
173
|
+
- **Several clients**, each with its own operations and secret, so one client cannot publish under another's name.
|
|
174
|
+
- **Archiving** operations that old app versions no longer use, without deleting their history.
|
|
175
|
+
- **Publishing without redeploying**: the server reads new operations as soon as they are synced.
|
|
176
|
+
|
|
177
|
+
Until then, any object that answers `fetch(id)` with an operation can be passed as `store:`.
|
|
178
|
+
|
|
179
|
+
## References
|
|
180
|
+
|
|
181
|
+
- Apollo Client: [Persisted queries](https://www.apollographql.com/docs/react/data/persisted-queries) — generating the manifest with `@apollo/generate-persisted-query-manifest` and sending ids.
|
|
182
|
+
- Apollo iOS: [Persisted queries](https://www.apollographql.com/docs/ios/fetching/apqs).
|
|
183
|
+
- Relay: [Persisted queries](https://relay.dev/docs/guides/persisted-queries/) — `persistConfig`, local persisting to a file, `doc_id` in the network layer.
|
|
184
|
+
- Relay: [Compiler config](https://relay.dev/docs/getting-started/compiler-config/).
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Armory
|
|
8
|
+
module PersistedQueries
|
|
9
|
+
# The operations a client build produced, loaded from Apollo's persisted query manifest or Relay's
|
|
10
|
+
# persisted output. Every id must be the digest of its query, so an edited manifest cannot put another
|
|
11
|
+
# query behind an id; every query is parsed once, here. Any problem raises at boot, and no message
|
|
12
|
+
# quotes the manifest's values: they may hold query text.
|
|
13
|
+
class Manifest
|
|
14
|
+
DIGESTS = { sha256: Digest::SHA256, sha1: Digest::SHA1, md5: Digest::MD5 }.freeze
|
|
15
|
+
APOLLO_FORMAT = "apollo-persisted-query-manifest"
|
|
16
|
+
ID_LIKE = /\A[A-Za-z0-9_.:-]{1,128}\z/
|
|
17
|
+
|
|
18
|
+
Operation = Struct.new(:id, :name, :body, :document, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
def self.load(path, digest: :sha256)
|
|
21
|
+
data = begin
|
|
22
|
+
JSON.parse(File.read(path, mode: "r:bom|utf-8"))
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
# Not the parser's message: it quotes the file's content.
|
|
25
|
+
raise ConfigurationError, "#{path} is not valid JSON"
|
|
26
|
+
rescue SystemCallError => error
|
|
27
|
+
raise ConfigurationError, "#{path} cannot be read (#{error.class})"
|
|
28
|
+
end
|
|
29
|
+
new(data, digest: digest)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `digest:` is how ids were made: :sha256 (Apollo), :md5 or :sha1 (Relay's options), or false for ids
|
|
33
|
+
# that are not digests (then nothing proves an id belongs to its query).
|
|
34
|
+
def initialize(data, digest: :sha256)
|
|
35
|
+
unless digest == false || DIGESTS.key?(digest)
|
|
36
|
+
raise ConfigurationError, "unknown digest #{digest.inspect}: use #{DIGESTS.keys.map(&:inspect).join(", ")} or false"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@digest = digest && DIGESTS.fetch(digest)
|
|
40
|
+
@operations = entries(data).each_with_index.with_object({}) do |((id, name, body), index), operations|
|
|
41
|
+
operation = operation(index, id, name, body)
|
|
42
|
+
raise ConfigurationError, "operation #{label(index, id)} appears twice in the manifest" if operations.key?(id)
|
|
43
|
+
|
|
44
|
+
operations[id] = operation
|
|
45
|
+
end.freeze
|
|
46
|
+
raise ConfigurationError, "the manifest holds no operations: is it the right file?" if @operations.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch(id)
|
|
50
|
+
@operations[id]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def size
|
|
54
|
+
@operations.size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# [id, name, body] triples from either format. A Relay map may have an operation keyed "format", so a
|
|
60
|
+
# Hash is Apollo's when its format says so, or when it has both "format" and "operations".
|
|
61
|
+
def entries(data)
|
|
62
|
+
raise ConfigurationError, unknown_format unless data.is_a?(Hash)
|
|
63
|
+
return apollo_entries(data) if data["format"] == APOLLO_FORMAT || (data.key?("format") && data.key?("operations"))
|
|
64
|
+
|
|
65
|
+
data.map { |id, body| [id, nil, body] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apollo_entries(data)
|
|
69
|
+
raise ConfigurationError, "unknown manifest format: expected #{APOLLO_FORMAT}" unless data["format"] == APOLLO_FORMAT
|
|
70
|
+
raise ConfigurationError, "unsupported manifest version: expected 1" unless data["version"].eql?(1)
|
|
71
|
+
raise ConfigurationError, "the manifest's operations must be an array" unless data["operations"].is_a?(Array)
|
|
72
|
+
|
|
73
|
+
data["operations"].each_with_index.map do |entry, index|
|
|
74
|
+
raise ConfigurationError, "manifest entry #{index} is not an object" unless entry.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
entry.values_at("id", "name", "body")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def operation(index, id, name, body)
|
|
81
|
+
unless id.is_a?(String) && body.is_a?(String) && (name.nil? || name.is_a?(String))
|
|
82
|
+
raise ConfigurationError, "manifest entry #{index} needs a String id, a String body and a String name or none"
|
|
83
|
+
end
|
|
84
|
+
if @digest && @digest.hexdigest(body) != id
|
|
85
|
+
raise ConfigurationError, "operation #{label(index, id)} is not the digest of its query: the manifest was edited, or `digest:` is wrong"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Copies, so neither the caller's data nor a fetched operation can change what a later fetch returns.
|
|
89
|
+
Operation.new(id: id.dup.freeze, name: name&.dup&.freeze, body: body.dup.freeze, document: parse(label(index, id), body)).freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse(label, body)
|
|
93
|
+
GraphQL.parse(body)
|
|
94
|
+
rescue GraphQL::ParseError
|
|
95
|
+
raise ConfigurationError, "operation #{label} does not parse"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The id names the operation in errors only when it looks like an id; otherwise it may be query text.
|
|
99
|
+
def label(index, id)
|
|
100
|
+
id.match?(ID_LIKE) ? id : "at entry #{index}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def unknown_format
|
|
104
|
+
"unknown manifest format: expected Apollo's persisted query manifest or Relay's persisted output"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
# Persisted queries: clients send the id of an operation their build produced, and the server runs the
|
|
8
|
+
# stored query. With strict mode on, it refuses queries it does not hold.
|
|
9
|
+
#
|
|
10
|
+
# class AppSchema < GraphQL::Schema
|
|
11
|
+
# use GraphQL::Armory::PersistedQueries, store: Manifest.load("persisted-queries.json"), strict: true
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# AppSchema.execute(params[:query], context: { operation_id: PersistedQueries.operation_id(params) })
|
|
15
|
+
#
|
|
16
|
+
# Hooks only `Schema.query_class`: the stored document goes to the query as `document:`, so it is parsed once,
|
|
17
|
+
# at boot, and runs the same on both engines.
|
|
18
|
+
module PersistedQueries
|
|
19
|
+
class ConfigurationError < Armory::Error; end
|
|
20
|
+
|
|
21
|
+
UNKNOWN_OPERATION = "Unknown operation id"
|
|
22
|
+
MISMATCH = "The operation id and the query do not match"
|
|
23
|
+
RAW_QUERY_REFUSED = "Only persisted operations are allowed: send an operation id"
|
|
24
|
+
|
|
25
|
+
# What a refused request holds: nothing to run.
|
|
26
|
+
EMPTY_DOCUMENT = GraphQL::Language::Nodes::Document.new(definitions: [])
|
|
27
|
+
|
|
28
|
+
# Validates nothing and answers the refusal, through the query's public `static_validator:` option.
|
|
29
|
+
class Refusal < GraphQL::StaticValidation::Validator
|
|
30
|
+
def initialize(schema, message)
|
|
31
|
+
super(schema: schema)
|
|
32
|
+
@message = message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate(_query, **)
|
|
36
|
+
{ errors: [GraphQL::StaticValidation::Error.new(@message)], remaining_timeout: nil }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `store:` answers `fetch(id)` with an operation (`body` and `document`) or nil: a Manifest, or your own.
|
|
41
|
+
# `strict: true` refuses raw queries unless the context holds `armory_allow_raw_query: true`.
|
|
42
|
+
def self.use(schema, store:, strict: false)
|
|
43
|
+
if store.is_a?(Hash) || !store.respond_to?(:fetch)
|
|
44
|
+
raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
|
|
45
|
+
end
|
|
46
|
+
raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
|
|
47
|
+
|
|
48
|
+
if schema.singleton_class.include?(Guard)
|
|
49
|
+
raise ConfigurationError, "#{schema} already uses PersistedQueries (here or in a parent schema): use it once"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
schema.instance_variable_set(:@armory_persisted_queries, [store, strict].freeze)
|
|
53
|
+
schema.singleton_class.prepend(Guard)
|
|
54
|
+
schema.query_class(schema.query_class)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Keeps the lookup in every query class the schema uses, including one set after `use` or by a child schema.
|
|
58
|
+
module Guard
|
|
59
|
+
UNSET = Object.new.freeze
|
|
60
|
+
|
|
61
|
+
def query_class(new_query_class = UNSET)
|
|
62
|
+
return super() if UNSET.equal?(new_query_class)
|
|
63
|
+
return super(new_query_class) if new_query_class.include?(Lookup)
|
|
64
|
+
|
|
65
|
+
super(Class.new(new_query_class) { include Lookup })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def armory_persisted_queries
|
|
69
|
+
@armory_persisted_queries || superclass.armory_persisted_queries
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The operation id a request sent, from Rack or Rails params: `operationId`, `extensions.operationId` or
|
|
74
|
+
# Apollo's `extensions.persistedQuery.sha256Hash` (`extensions` may arrive as a JSON string on GET).
|
|
75
|
+
# Answers nil when there is none, or when it is not a non-empty String.
|
|
76
|
+
def self.operation_id(params)
|
|
77
|
+
params = plain(params)
|
|
78
|
+
extensions = params["extensions"]
|
|
79
|
+
extensions = parse_json(extensions) if extensions.is_a?(String)
|
|
80
|
+
extensions = plain(extensions)
|
|
81
|
+
persisted_query = plain(extensions["persistedQuery"])
|
|
82
|
+
|
|
83
|
+
[params["operationId"], extensions["operationId"], persisted_query["sha256Hash"]].find do |candidate|
|
|
84
|
+
candidate.is_a?(String) && !candidate.empty?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.plain(value)
|
|
89
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
90
|
+
value.is_a?(Hash) ? value : {}
|
|
91
|
+
end
|
|
92
|
+
private_class_method :plain
|
|
93
|
+
|
|
94
|
+
def self.parse_json(text)
|
|
95
|
+
JSON.parse(text)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
private_class_method :parse_json
|
|
100
|
+
|
|
101
|
+
# Swaps the request's query for the stored document, or for nothing with an error when it is refused.
|
|
102
|
+
module Lookup
|
|
103
|
+
def initialize(schema, query_string = nil, **options)
|
|
104
|
+
store, strict = schema.armory_persisted_queries
|
|
105
|
+
context = options[:context]
|
|
106
|
+
operation_id = context[:operation_id] if context.respond_to?(:[])
|
|
107
|
+
text = query_string || options[:query]
|
|
108
|
+
text = nil if text == ""
|
|
109
|
+
|
|
110
|
+
refusal, document =
|
|
111
|
+
if !operation_id.nil?
|
|
112
|
+
operation = operation_id.is_a?(String) ? store.fetch(operation_id) : nil
|
|
113
|
+
if operation.nil?
|
|
114
|
+
UNKNOWN_OPERATION
|
|
115
|
+
elsif options[:document] || (text && !same_query?(text, operation))
|
|
116
|
+
MISMATCH
|
|
117
|
+
else
|
|
118
|
+
[nil, operation.document]
|
|
119
|
+
end
|
|
120
|
+
elsif strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
|
|
121
|
+
RAW_QUERY_REFUSED
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
return super if refusal.nil? && document.nil?
|
|
125
|
+
return super(schema, nil, **options, query: nil, document: document) if refusal.nil?
|
|
126
|
+
|
|
127
|
+
# A refused request fails validation with the refusal as its only error, so neither engine runs it. Its
|
|
128
|
+
# text is never parsed; a document the app built itself is kept, for `Schema.validate`, which validates it
|
|
129
|
+
# with its own validator.
|
|
130
|
+
super(schema, nil, **options, query: nil, document: options[:document] || EMPTY_DOCUMENT, validate: true, static_validator: Refusal.new(schema, refusal))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
|
|
136
|
+
def same_query?(text, operation)
|
|
137
|
+
text == operation.body || text == operation.document.to_query_string
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
require "graphql/armory/persisted_queries/manifest"
|
data/lib/graphql/armory.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphql-armory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.pre4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Israel
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- README.md
|
|
44
44
|
- docs/authorization.md
|
|
45
45
|
- docs/encrypted-ids.md
|
|
46
|
+
- docs/persisted-queries.md
|
|
46
47
|
- lib/graphql-armory.rb
|
|
47
48
|
- lib/graphql/armory.rb
|
|
48
49
|
- lib/graphql/armory/authorization.rb
|
|
@@ -79,6 +80,8 @@ files:
|
|
|
79
80
|
- lib/graphql/armory/encoder/legacy.rb
|
|
80
81
|
- lib/graphql/armory/encoder/versioned.rb
|
|
81
82
|
- lib/graphql/armory/ids.rb
|
|
83
|
+
- lib/graphql/armory/persisted_queries.rb
|
|
84
|
+
- lib/graphql/armory/persisted_queries/manifest.rb
|
|
82
85
|
- lib/graphql/armory/version.rb
|
|
83
86
|
- lib/tasks/graphql_armory.rake
|
|
84
87
|
homepage: https://github.com/israelb/graphql-armory
|