graphql-armory 0.1.0.pre3 → 0.1.0.pre5
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 +18 -0
- data/README.md +6 -6
- data/docs/encrypted-ids.md +4 -0
- data/docs/persisted-queries.md +188 -0
- data/docs/rate-limiting.md +293 -0
- data/lib/graphql/armory/encoder/null.rb +18 -0
- data/lib/graphql/armory/encoder/substitute.rb +47 -0
- data/lib/graphql/armory/encoder.rb +2 -0
- data/lib/graphql/armory/ids/controls.rb +24 -0
- data/lib/graphql/armory/ids.rb +15 -2
- data/lib/graphql/armory/persisted_queries/controls.rb +41 -0
- data/lib/graphql/armory/persisted_queries/manifest.rb +109 -0
- data/lib/graphql/armory/persisted_queries/store.rb +48 -0
- data/lib/graphql/armory/persisted_queries.rb +169 -0
- data/lib/graphql/armory/rate_limits/concurrency.rb +127 -0
- data/lib/graphql/armory/rate_limits/controls.rb +40 -0
- data/lib/graphql/armory/rate_limits/memory_store.rb +64 -0
- data/lib/graphql/armory/rate_limits/points.rb +126 -0
- data/lib/graphql/armory/rate_limits/redis_store.rb +133 -0
- data/lib/graphql/armory/rate_limits/store.rb +114 -0
- data/lib/graphql/armory/rate_limits.rb +116 -0
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +2 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 431eab2604e80c473f2037cf636158113056cbd60e9fb408c8f6f91696586b55
|
|
4
|
+
data.tar.gz: aaa7d67cd478e2a0b8faf0b069eeb3e7a8051e2304caa55c8e522ddcc1a121bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 308f9883d87cd8f5ec8ebc711477779d95a3a149c3fb4e770f9866fc10f6f476c99a788c8493fa03317f3963f71f0aab8da786904ecb78c3ac269f82345ae565
|
|
7
|
+
data.tar.gz: 31179d1a3c3a4b7d5a71027d2b1ce18f983843b10c87e6418c9763a89a33ec33dd7669fdc1bc3dddc2abe0876f37716495c50b4fb05393c58f8b9a38c79015e3
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.0.pre5 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
Rate limiting (roadmap module 4). Docs: `docs/rate-limiting.md`. All four modules now follow the Useful Objects doctrine.
|
|
8
|
+
|
|
9
|
+
- Rate limiting, part 1: `use GraphQL::Armory::RateLimits::Points, store:, limit:, window:` charges each query its complexity against a bucket of points per client (`context[:rate_limit_key]`) that refills over the window. Over-limit queries are refused before running, with their cost, what remains and when to retry; soft mode (the default) only records. `RateLimits::MemoryStore` for tests and single processes. Docs: `docs/rate-limiting.md`.
|
|
10
|
+
- Rate limiting, part 2: `use GraphQL::Armory::RateLimits::Concurrency, store:, limit:, stale_after: 60` lets each client run at most `limit` operations at the same time. A query analyzer takes a slot before the operation runs (or refuses it, `RATE_LIMITED`); a trace releases every slot when the multiplex ends, even when it raises. Slots expire after `stale_after` seconds. `MemoryStore` gains `acquire` and `release`.
|
|
11
|
+
- Rate limiting, part 3: clients can see their budget on every response. `Points` takes `extensions: true` to add `rateLimit` (`cost`, `limit`, `remaining`, `resetIn`) to the response extensions, and `Points.headers(result)` answers `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`.
|
|
12
|
+
- Rate limiting, part 4: `RateLimits::RedisStore.new(redis:)` keeps buckets and slots in Redis, shared by every process. Each change is one Lua script and the time comes from Redis, so concurrent requests never race and servers agree on the clock. Keys expire on their own. The app adds the redis gem.
|
|
13
|
+
- Rate limiting follows the Useful Objects doctrine like authorization: `Points.new` and `Concurrency.new` are inert and refuse every query (`Store::Null`), `build` checks the settings and wires the store, `call` is the actuator. `RateLimits::Store::Substitute` (a MemoryStore on a test clock that records calls and fails when told) and `RateLimits::Controls` are there for your specs.
|
|
14
|
+
- Encrypted IDs and persisted queries follow the same doctrine: `IDs::Lookup.new` is inert (`Encoder::Null`) and `build` wires `Encoder.default`; `PersistedQueries::Gate` holds the decision, with an inert `new` (`Store::Null`, strict) and `build`. `Encoder::Substitute`, `PersistedQueries::Store::Substitute` and `Controls` for both are there for your specs. `IDs` now refuses at boot a `find:` that is not callable and an encoder without `encode` and `decode`.
|
|
15
|
+
|
|
16
|
+
## 0.1.0.pre4 (2026-10-29)
|
|
17
|
+
|
|
18
|
+
Persisted queries / operation allowlist (roadmap module 3). Docs: `docs/persisted-queries.md`.
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
- 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`.
|
|
22
|
+
|
|
5
23
|
## 0.1.0.pre3 (2026-10-29)
|
|
6
24
|
|
|
7
25
|
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.pre5`). 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.pre5" # a prerelease: `gem install graphql-armory --pre`
|
|
15
15
|
gem "pundit" # or "cancancan", for the authorization module
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -24,9 +24,9 @@ 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
|
|
29
|
-
| 4 | Rate limiting |
|
|
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
|
+
| 4 | Rate limiting | ✅ | [docs/rate-limiting.md](docs/rate-limiting.md) | Released in 0.1.0.pre5 (prerelease). Points per client (query complexity) and operations at the same time, `MemoryStore` and `RedisStore`, the budget in response extensions and headers. A processing-time layer is planned for later. |
|
|
30
30
|
| 5 | Stable (keyset) connections | ⬜ | | Research not started. |
|
|
31
31
|
| 6 | Subscriptions over Pusher / Ably | ⬜ | | Research not started. |
|
|
32
32
|
| 7 | Response caching | ⬜ | | 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.pre5 && git push origin v0.1.0.pre5
|
|
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).
|
data/docs/encrypted-ids.md
CHANGED
|
@@ -103,6 +103,10 @@ return nil if encoder.nil? # nothing in the cha
|
|
|
103
103
|
log_legacy_id(id) if encoder == GraphQL::Armory::Encoder::Legacy
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
### Testing with IDs
|
|
107
|
+
|
|
108
|
+
`Encoder::Substitute` encodes reversibly without a key and records what it encoded and decoded, for specs of code that uses an encoder; never hand out real IDs with it. `IDs::Controls::Encoder.example` is an Encoder subclass with a fixed test key, and `IDs::Controls::Record.example(id:)` a record to build IDs from. `IDs::Lookup.new`, with nothing wired, hands out no ID and honours none (`Encoder::Null`); `IDs::Lookup.build(encoder:, find:)` takes the same settings as `use`.
|
|
109
|
+
|
|
106
110
|
## Reference
|
|
107
111
|
|
|
108
112
|
| Declaration | Meaning |
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
### Testing with persisted queries
|
|
140
|
+
|
|
141
|
+
`PersistedQueries::Store::Substitute.new(*queries)` holds the given queries under their SHA-256, like an Apollo manifest, and records the ids it was asked for (`fetched`). `PersistedQueries::Controls::Operations`, `Query.example` and `Root.example` are a small schema and its operations to test against. The decision itself is `PersistedQueries::Gate`: `Gate.build(store:, strict:)` takes the same settings as `use`, and `gate.(context, query_text, document)` answers `[refusal, document]`. `Gate.new`, with nothing wired, refuses every request (`Store::Null`, strict).
|
|
142
|
+
|
|
143
|
+
## Reference
|
|
144
|
+
|
|
145
|
+
`Manifest.new(data, digest: :sha256)`, `Manifest.load(path, digest: :sha256)`
|
|
146
|
+
|
|
147
|
+
- Reads Apollo's persisted query manifest (`format: "apollo-persisted-query-manifest"`, `version: 1`) or Relay's flat map.
|
|
148
|
+
- `digest:` is `:sha256`, `:sha1`, `:md5` or `false`.
|
|
149
|
+
- `fetch(id)` answers the operation (`id`, `name`, `body`, `document`, all frozen) or nil. `size` counts them.
|
|
150
|
+
- 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.
|
|
151
|
+
|
|
152
|
+
`use GraphQL::Armory::PersistedQueries, store:, strict: false`
|
|
153
|
+
|
|
154
|
+
- `store:` answers `fetch(id)` with an object that has `body` and `document`, or nil. A Manifest, or your own store.
|
|
155
|
+
- `strict:` is `true` or `false`.
|
|
156
|
+
- Works with your own `query_class`, whether it is set before `use`, after it, or in a child schema: the plugin subclasses it.
|
|
157
|
+
- `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.
|
|
158
|
+
- A store whose `fetch` raises or answers something without `document` makes the request raise: it fails closed, as an exception.
|
|
159
|
+
|
|
160
|
+
Context keys: `operation_id` (a String; anything else is an unknown id), `armory_allow_raw_query` (only `true` allows).
|
|
161
|
+
|
|
162
|
+
## Guarantees and limits
|
|
163
|
+
|
|
164
|
+
- A request with an id runs only the stored document for that id, or nothing.
|
|
165
|
+
- 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.
|
|
166
|
+
- 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.
|
|
167
|
+
- 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.
|
|
168
|
+
- The manifest lives in the server's code: adding an operation needs a deploy. See the runtime store below.
|
|
169
|
+
- 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.
|
|
170
|
+
|
|
171
|
+
## Planned: a runtime store
|
|
172
|
+
|
|
173
|
+
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:
|
|
174
|
+
|
|
175
|
+
- **A database table** (ActiveRecord) holding operations per client, with the digest checked on write.
|
|
176
|
+
- **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.
|
|
177
|
+
- **Several clients**, each with its own operations and secret, so one client cannot publish under another's name.
|
|
178
|
+
- **Archiving** operations that old app versions no longer use, without deleting their history.
|
|
179
|
+
- **Publishing without redeploying**: the server reads new operations as soon as they are synced.
|
|
180
|
+
|
|
181
|
+
Until then, any object that answers `fetch(id)` with an operation can be passed as `store:`.
|
|
182
|
+
|
|
183
|
+
## References
|
|
184
|
+
|
|
185
|
+
- 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.
|
|
186
|
+
- Apollo iOS: [Persisted queries](https://www.apollographql.com/docs/ios/fetching/apqs).
|
|
187
|
+
- Relay: [Persisted queries](https://relay.dev/docs/guides/persisted-queries/) — `persistConfig`, local persisting to a file, `doc_id` in the network layer.
|
|
188
|
+
- Relay: [Compiler config](https://relay.dev/docs/getting-started/compiler-config/).
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Rate limiting
|
|
2
|
+
|
|
3
|
+
Each client gets a budget of work per minute, measured in what its queries cost, not in how many requests it sends.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
A REST API can limit requests per minute because requests cost roughly the same. GraphQL requests do not:
|
|
8
|
+
|
|
9
|
+
```graphql
|
|
10
|
+
{ me { name } } # almost free
|
|
11
|
+
{ invoices(first: 1000) { lines { product { reviews { author { name } } } } } } # seconds of work
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A limit of 100 requests per minute lets a client send 100 of the second kind, by mistake or on purpose, and keep your servers busy while everyone else waits. Counting requests does not stop it.
|
|
15
|
+
|
|
16
|
+
## The solution
|
|
17
|
+
|
|
18
|
+
`GraphQL::Armory::RateLimits::Points` charges every query its **complexity**: the score graphql-ruby already computes from the fields a query asks for, before running it. Each client has a bucket of points that refills over a window:
|
|
19
|
+
|
|
20
|
+
- **Refused before running.** A query that costs more than the client has left is refused before any field resolves, so it costs your servers nothing. It is not charged either.
|
|
21
|
+
- **Predictable for clients.** The refusal says what the query cost, what remains and when to retry, so a client can adapt.
|
|
22
|
+
- **Per client.** Each client key (a user, an API token, an IP) has its own bucket; you can give each key its own limit.
|
|
23
|
+
- **Safe to roll out.** Soft mode, the default, charges and records but refuses nothing, so you can watch real traffic before turning it on.
|
|
24
|
+
|
|
25
|
+
`GraphQL::Armory::RateLimits::Concurrency` adds a second rule: at most N operations running at the same time per client ([Operations at the same time](#operations-at-the-same-time)). Both keep their state in a store: `MemoryStore` for tests and one process, `RedisStore` for production ([Sharing limits between processes](#sharing-limits-between-processes)). Planned: a layer that measures processing time (see [Planned](#planned)).
|
|
26
|
+
|
|
27
|
+
## How to use it
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class AppSchema < GraphQL::Schema
|
|
31
|
+
use GraphQL::Armory::RateLimits::Points,
|
|
32
|
+
store: GraphQL::Armory::RateLimits::MemoryStore.new,
|
|
33
|
+
limit: 1_000, # points
|
|
34
|
+
window: 60, # seconds: the bucket refills 1,000 points every minute
|
|
35
|
+
soft: false # refuse; leave it out (soft mode) while you measure
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Name the client in the context:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
context = {
|
|
43
|
+
current_user: current_user,
|
|
44
|
+
rate_limit_key: current_user ? "user:#{current_user.id}" : "ip:#{request.remote_ip}"
|
|
45
|
+
}
|
|
46
|
+
AppSchema.execute(params[:query], variables: params[:variables], context: context)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A query without a key raises `RateLimits::ConfigurationError`, so a forgotten controller is caught at once instead of running unlimited. To build the key another way, pass `key: ->(query) { ... }`.
|
|
50
|
+
|
|
51
|
+
### What a query costs
|
|
52
|
+
|
|
53
|
+
graphql-ruby scores a query before running it:
|
|
54
|
+
|
|
55
|
+
- Every field costs **1 point**, unless you give it another score.
|
|
56
|
+
- Inside a connection, the fields selected under each node cost **page size × their points** (`first:` or `last:`, variables included).
|
|
57
|
+
- Fractional complexities are charged in whole points, rounded up.
|
|
58
|
+
|
|
59
|
+
A worked example:
|
|
60
|
+
|
|
61
|
+
```graphql
|
|
62
|
+
query {
|
|
63
|
+
viewer { # 1
|
|
64
|
+
invoices(first: 50) { # 1
|
|
65
|
+
nodes { # 1
|
|
66
|
+
number # 50 invoices × 1
|
|
67
|
+
lines(first: 20) { # 50 × 1
|
|
68
|
+
nodes { # 50 × 1
|
|
69
|
+
product # 50 × 20 lines × 1
|
|
70
|
+
comments(first: 10) { # 50 × 20 × 1
|
|
71
|
+
nodes { # 50 × 20 × 1
|
|
72
|
+
body # 50 × 20 × 10 comments × 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
followers(first: 10) { # 1
|
|
80
|
+
nodes { # 1
|
|
81
|
+
name # 10 followers × 1
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Calculation:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
3 = viewer, invoices, nodes
|
|
92
|
+
+ 50 × 3 = 150 = number, lines, nodes, for each invoice
|
|
93
|
+
+ 50 × 20 × 3 = 3,000 = product, comments, nodes, for each line
|
|
94
|
+
+ 50 × 20 × 10 × 1 = 10,000 = body, for each comment
|
|
95
|
+
+ 2 + 10 × 1 = 12 = followers, nodes, and name for each follower
|
|
96
|
+
= 13,165 points
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
With `limit: 1_000` this query can never run; asking for `invoices(first: 5)` instead costs about a tenth. The numbers were measured with graphql-ruby's own analysis, so they are exactly what the limiter charges.
|
|
100
|
+
|
|
101
|
+
Give expensive fields a higher score where you define them, so their cost reflects real work:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
field :report, ReportType, null: false, complexity: 40 # 40 points instead of 1
|
|
105
|
+
field :create_invoice, mutation: Mutations::CreateInvoice, complexity: 10 # 10 points plus its selections
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Three settings make the scores fair. Large public APIs (GitHub, Shopify, see [References](#references)) do the same:
|
|
109
|
+
|
|
110
|
+
- **Paginate every list.** A plain list field (`[Invoice]`) costs its fields **once**, however many items it returns, so 10,000 invoices cost like one. Use connections for anything that can grow. A connection asked for without `first:` or `last:` is sized by the schema's `default_page_size`, or else by `default_max_page_size`. If neither is set, graphql-ruby raises `GraphQL::Error` while scoring the query (a server error, not a refusal), so set one of them. GitHub goes further and requires `first:` or `last:` on every connection.
|
|
111
|
+
- **Cap a single query.** graphql-ruby's `max_complexity 1_000` refuses any query above that score, whatever the client's budget. Shopify caps single queries at 1,000 points.
|
|
112
|
+
- **Make writes cost more.** Give mutations a higher `complexity:`: `complexity: 10` charges 10 points for the mutation field itself, plus the fields it selects. Shopify charges 10 points per mutation by default.
|
|
113
|
+
|
|
114
|
+
See graphql-ruby's [complexity and depth guide](https://graphql-ruby.org/queries/complexity_and_depth) for computed costs.
|
|
115
|
+
|
|
116
|
+
### When a client runs out
|
|
117
|
+
|
|
118
|
+
The response holds only the refusal:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"errors": [{
|
|
123
|
+
"message": "Rate limit exceeded: this query costs 40 points and 20 remain (1000 every 60 seconds). Retry in 2 seconds.",
|
|
124
|
+
"extensions": { "code": "RATE_LIMITED", "cost": 40, "remaining": 20, "retryAfter": 2 }
|
|
125
|
+
}]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A query that costs more than the whole limit can never run, and says so: `this query costs 1200 points and the limit is 1000 every 60 seconds, so it can never run.` Its extensions hold `cost` and `limit`.
|
|
130
|
+
|
|
131
|
+
### Showing clients their budget
|
|
132
|
+
|
|
133
|
+
A client that only learns its budget when it is refused cannot pace itself. GitHub sends the budget in headers on every response; Shopify puts it in the response's `extensions`. You can do either, or both.
|
|
134
|
+
|
|
135
|
+
In the response, with `extensions: true`:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
use GraphQL::Armory::RateLimits::Points, store: store, limit: 1_000, window: 60, soft: false, extensions: true
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"data": { "report": { "total": 120 } },
|
|
144
|
+
"extensions": { "rateLimit": { "cost": 40, "limit": 1000, "remaining": 960, "resetIn": 3 } }
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`resetIn` is the number of seconds until the bucket is full again. It is on refused responses too, and absent for a client with no limit. It is off by default, so responses do not change until you turn it on.
|
|
149
|
+
|
|
150
|
+
In HTTP headers, from your controller:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
result = AppSchema.execute(params[:query], variables: params[:variables], context: context)
|
|
154
|
+
response.headers.merge!(GraphQL::Armory::RateLimits::Points.headers(result))
|
|
155
|
+
render json: result
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
That sets `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the bucket is full), or nothing when the query had no limit. It works whether or not `extensions:` is on, and reads each query's own budget, also in a multiplex. The `rateLimit` key of the response extensions belongs to the limiter: do not write it yourself.
|
|
159
|
+
|
|
160
|
+
### Different limits per client
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
use GraphQL::Armory::RateLimits::Points, store: store, window: 60,
|
|
164
|
+
limit: ->(key, query) { key.start_with?("partner:") ? 10_000 : 1_000 }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The callable answers a positive Integer, or nil for no limit (for example an internal service). Anything else raises.
|
|
168
|
+
|
|
169
|
+
### Operations at the same time
|
|
170
|
+
|
|
171
|
+
Points limit how much work a client asks for per minute. They do not stop a client that sends 30 cheap requests at once and occupies every server process while they run: GitHub, for one, also caps concurrent requests. Add `Concurrency` next to `Points`:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class AppSchema < GraphQL::Schema
|
|
175
|
+
use GraphQL::Armory::RateLimits::Concurrency,
|
|
176
|
+
store: store, # the same store as Points
|
|
177
|
+
limit: 5, # operations running at once per client
|
|
178
|
+
soft: false
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
It uses the same `rate_limit_key`. Before an operation runs it takes one of the client's slots; when the operation ends, it gives the slot back, even if the operation raised or another analyzer refused it. A client whose slots are all taken is refused before any field resolves:
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"errors": [{
|
|
187
|
+
"message": "Too many operations at the same time: 5 are already running for this client (limit 5). Retry when one finishes.",
|
|
188
|
+
"extensions": { "code": "RATE_LIMITED", "limit": 5, "active": 5 }
|
|
189
|
+
}]
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
A process that crashes never gives its slots back, so each slot expires after `stale_after:` seconds (60 by default). Set it above your longest request timeout. Soft mode, per-key limits (`limit: ->(key, query) { ... }`) and `on_store_error:` work as for `Points`; what happened is in `result.context[:rate_limit_concurrency]` (`{ key:, limit:, active:, soft:, limited: }`).
|
|
194
|
+
|
|
195
|
+
### Rolling it out
|
|
196
|
+
|
|
197
|
+
1. Install it in soft mode (leave out `soft:`). Queries run as before; each one records what it cost.
|
|
198
|
+
2. Send what it recorded to your metrics: `result.context[:rate_limit_points]` is `{ key:, cost:, limit:, remaining:, window:, soft:, limited: }`, and `limited: true` marks the queries that would have been refused.
|
|
199
|
+
3. When the limits fit your traffic, set `soft: false`.
|
|
200
|
+
|
|
201
|
+
### Sharing limits between processes
|
|
202
|
+
|
|
203
|
+
`MemoryStore` keeps buckets in one process: with four Puma workers on two servers, a client gets the limit eight times over, and a restart refills every bucket. In production use `RedisStore`, which keeps them in Redis, shared by every process:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# Gemfile: gem "redis"
|
|
207
|
+
RATE_LIMIT_STORE = GraphQL::Armory::RateLimits::RedisStore.new(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
|
|
208
|
+
|
|
209
|
+
class AppSchema < GraphQL::Schema
|
|
210
|
+
use GraphQL::Armory::RateLimits::Points, store: RATE_LIMIT_STORE, limit: 1_000, window: 60, soft: false
|
|
211
|
+
use GraphQL::Armory::RateLimits::Concurrency, store: RATE_LIMIT_STORE, limit: 5, soft: false
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **Atomic.** Each charge, slot taken and slot released is one Lua script, so two requests of the same client never both spend the last points.
|
|
216
|
+
- **One clock.** The time comes from Redis (`TIME`), not from each server, so servers whose clocks drift still agree on when a bucket refills.
|
|
217
|
+
- **Small.** A client costs two keys, `graphql-armory:rate-limits:points:<key>` and `...:slots:<key>`; both expire once they no longer matter (a window, or `stale_after`, after the last use). Pass `prefix:` to share one Redis with other apps.
|
|
218
|
+
- **Fails open.** When Redis is down or slow, the client raises, the query runs, and `on_store_error` is called: make that hook alert. Set the client's timeouts (for example `Redis.new(url: ..., timeout: 0.2)`) so a slow Redis cannot slow every request.
|
|
219
|
+
|
|
220
|
+
The gem does not depend on the redis gem: add it to your Gemfile. A single `Redis` client is safe to share between threads.
|
|
221
|
+
|
|
222
|
+
### Testing your limits
|
|
223
|
+
|
|
224
|
+
`RateLimits::Store::Substitute` is a `MemoryStore` on a clock your test moves, that records its calls and fails when told, so specs need no mocks and no sleeping:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
store = GraphQL::Armory::RateLimits::Store::Substitute.new
|
|
228
|
+
schema = Class.new(AppSchema) { use GraphQL::Armory::RateLimits::Points, store: store, limit: 100, window: 60, soft: false }
|
|
229
|
+
|
|
230
|
+
store.now += 30 # half a window later
|
|
231
|
+
store.fail_with(IOError.new("down"), on: :take) # the next charges raise, as a broken Redis would
|
|
232
|
+
store.answer(:take, [0, 0]) # an answer no real store gives
|
|
233
|
+
store.calls # [[:take, "user:1", { cost: 40, limit: 100, window: 60, force: false }], ...]
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`occupy(key)` holds a slot for an operation running elsewhere and `held(key)` counts the slots held. `RateLimits::Controls::Query.example`, `Root.example` and `Context.example` are a small schema and context to test against. `Points.new` and `Concurrency.new`, with nothing wired, refuse every query (`Store::Null`), and check every setting they are given: `soft: true` with no store raises, since it would count nothing; `Points.build(...)` and `Concurrency.build(...)` take the same settings as `use`.
|
|
237
|
+
|
|
238
|
+
## Reference
|
|
239
|
+
|
|
240
|
+
`use GraphQL::Armory::RateLimits::Points, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false`
|
|
241
|
+
|
|
242
|
+
- `store:` answers `take(key, cost:, limit:, window:, force:)` with `[taken, remaining]`. `MemoryStore.new(clock: ...)` keeps buckets in the process; `RedisStore` shares them (below).
|
|
243
|
+
- `limit:` a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
|
|
244
|
+
- `window:` positive seconds over which `limit` points refill, continuously.
|
|
245
|
+
- `soft:` `true` (default) charges and records without refusing; `false` refuses.
|
|
246
|
+
- `key:` a callable `(query)` answering the client key; by default `context[:rate_limit_key]`. It must be a non-empty String.
|
|
247
|
+
- `on_store_error:` a callable `(error, query)` called when the store raises; the query then runs (fails open). By default it warns.
|
|
248
|
+
- `extensions:` `true` adds `"rateLimit" => { cost, limit, remaining, resetIn }` to every response of a limited client; `false` (default) leaves responses unchanged.
|
|
249
|
+
- `Points.headers(result)` answers the `RateLimit-*` headers for a result, or `{}`.
|
|
250
|
+
- Instrumentation: `context[:rate_limit_points]`.
|
|
251
|
+
|
|
252
|
+
`use GraphQL::Armory::RateLimits::Concurrency, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil`
|
|
253
|
+
|
|
254
|
+
- `store:` answers `acquire(key, token:, limit:, ttl:, force:)` with `[acquired, active]` and `release(key, token:)`. `MemoryStore` does both.
|
|
255
|
+
- `limit:` operations at once per client: a positive Integer, or a callable `(key, query)` answering a positive Integer or nil.
|
|
256
|
+
- `stale_after:` positive seconds after which a slot that was never released expires.
|
|
257
|
+
- `soft:`, `key:` and `on_store_error:` as for `Points`. When the store raises on release, `on_store_error` is called and the slot expires after `stale_after`.
|
|
258
|
+
- Instrumentation: `context[:rate_limit_concurrency]`.
|
|
259
|
+
|
|
260
|
+
`GraphQL::Armory::RateLimits::RedisStore.new(redis:, prefix: "graphql-armory:rate-limits:", clock: nil)`
|
|
261
|
+
|
|
262
|
+
- `redis:` a redis-rb client (>= 4.8), or anything answering `eval(script, keys:, argv:)` and `evalsha(sha, keys:, argv:)` the same way. Needs Redis 5 or later. Works with Redis Cluster: each script touches one key.
|
|
263
|
+
- `prefix:` a non-empty String put before every key.
|
|
264
|
+
- `clock:` nil (default) reads Redis's clock; a callable answering seconds replaces it, for tests.
|
|
265
|
+
|
|
266
|
+
Invalid settings raise `RateLimits::ConfigurationError` at boot.
|
|
267
|
+
|
|
268
|
+
## Guarantees and limits
|
|
269
|
+
|
|
270
|
+
- A query this limiter refuses resolves no field and is not charged. A query that fails validation is not charged. A query refused by another analyzer (`max_depth`, `max_complexity`) is still charged: graphql-ruby runs every analyzer.
|
|
271
|
+
- Each query in a multiplex is charged on its own.
|
|
272
|
+
- `Concurrency` takes one slot per query of a multiplex, and releases them all when the multiplex ends, even when the queries share one context hash. If a release fails, that slot expires after `stale_after`; the others are still released.
|
|
273
|
+
- A query run with its own trace (`context: { trace: ... }`) cannot be released by `Concurrency`, so it raises `ConfigurationError` instead of taking a slot.
|
|
274
|
+
- A resolver that runs another query for the same client (a nested `execute`) takes a second slot while the first is held.
|
|
275
|
+
- A subscription holds a slot only while its first result runs, not while it stays subscribed.
|
|
276
|
+
- When queries of a multiplex share one context hash, `context[:rate_limit_points]` and `context[:rate_limit_concurrency]` hold the last query's record: give each query its own context hash to read every record.
|
|
277
|
+
- The cost is graphql-ruby's estimate before running, not the time a query really takes: a field that is slow but scored 1 costs 1. Score expensive fields, and add the processing-time layer when it ships.
|
|
278
|
+
- Field scores are yours: graphql-ruby treats a negative or NaN complexity as 0, so such a field is free, like `complexity: 0`. A complexity that is nil or a String makes graphql-ruby raise before the limiter runs, so the query does not run.
|
|
279
|
+
- When the store fails, queries run unlimited and `on_store_error` is called. Choose that hook to alert. `RedisStore` also counts a bucket whose fields were changed into something that is not a number as a store failure, rather than reading it as full.
|
|
280
|
+
- `MemoryStore` keeps buckets and slots in one process and forgets them on restart: with several processes, each one gives a client the full limit. It keeps one entry per client key it has seen. Use `RedisStore` in production.
|
|
281
|
+
|
|
282
|
+
## Planned
|
|
283
|
+
|
|
284
|
+
- **Processing time, a second layer:** a budget of milliseconds per client per window, measured as queries run, for costs the complexity score does not see.
|
|
285
|
+
|
|
286
|
+
## References
|
|
287
|
+
|
|
288
|
+
The same technique, charging queries a calculated cost against a budget per client, is used by large public GraphQL APIs:
|
|
289
|
+
|
|
290
|
+
- GitHub: [Rate limits and query limits for the GraphQL API](https://docs.github.com/en/graphql/overview/rate-limits-and-query-limits-for-the-graphql-api): a points budget per hour, where connections' `first`/`last` drive the cost, and a `rateLimit` field showing what remains.
|
|
291
|
+
- Shopify: [GraphQL Admin API rate limits](https://shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits): a calculated query cost against a bucket that refills continuously; Shopify also refunds the difference when the real cost is lower than the estimate.
|
|
292
|
+
- Cha et al., [A Principled Approach to GraphQL Query Cost Analysis](https://arxiv.org/abs/2009.05632) (ESEC/FSE 2020, IBM Research): estimating a query's cost statically, before running it, and why naive estimates over- or under-count.
|
|
293
|
+
- graphql-ruby: [Complexity and depth](https://graphql-ruby.org/queries/complexity_and_depth), the cost this limiter charges.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
class Encoder
|
|
6
|
+
# The encoder of an object nobody wired: it hands out no ID and honours none.
|
|
7
|
+
module Null
|
|
8
|
+
def self.encode(_text, nonce: false)
|
|
9
|
+
raise ConfigurationError, "no encoder wired: give an Encoder (or use Encoder.default)"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.decode(_text, nonce: false)
|
|
13
|
+
raise InvalidError
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
class Encoder
|
|
6
|
+
# Encodes reversibly without a key or any cryptography, records what it encoded and decoded, and fails when
|
|
7
|
+
# told: for tests of the objects that use an encoder. Never use it to hand out real IDs: anyone can forge them.
|
|
8
|
+
class Substitute
|
|
9
|
+
PREFIX = "substitute:"
|
|
10
|
+
|
|
11
|
+
attr_reader :encoded, :decoded
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@encoded = []
|
|
15
|
+
@decoded = []
|
|
16
|
+
@failure = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Raises `error` from every encode and decode until `recover`.
|
|
20
|
+
def fail_with(error)
|
|
21
|
+
@failure = error
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def recover
|
|
25
|
+
@failure = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def encode(text, nonce: false)
|
|
29
|
+
@encoded << text.to_s
|
|
30
|
+
raise @failure if @failure
|
|
31
|
+
|
|
32
|
+
PREFIX + URLSafeBase64.encode(text.to_s.b)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def decode(text, nonce: false)
|
|
36
|
+
@decoded << text.to_s
|
|
37
|
+
raise @failure if @failure
|
|
38
|
+
raise InvalidError unless text.to_s.start_with?(PREFIX)
|
|
39
|
+
|
|
40
|
+
URLSafeBase64.decode(text.to_s.delete_prefix(PREFIX)).force_encoding(Encoding::UTF_8)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
raise InvalidError
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|