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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module IDs
|
|
6
|
+
# Test data for encrypted IDs, reusable by apps and other gems.
|
|
7
|
+
module Controls
|
|
8
|
+
# An Encoder subclass with a fixed test key. Never use its key in production.
|
|
9
|
+
module Encoder
|
|
10
|
+
def self.example(key: "k" * 32)
|
|
11
|
+
Class.new(Armory::Encoder) { key(key) }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# A record with an id, as `id_from_object` receives it.
|
|
16
|
+
module Record
|
|
17
|
+
def self.example(id: 1)
|
|
18
|
+
Struct.new(:id).new(id)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/graphql/armory/ids.rb
CHANGED
|
@@ -17,7 +17,7 @@ module GraphQL
|
|
|
17
17
|
SEPARATOR = "/"
|
|
18
18
|
|
|
19
19
|
def self.use(schema, encoder: nil, find: nil)
|
|
20
|
-
lookup = Lookup.
|
|
20
|
+
lookup = Lookup.build(encoder: encoder, find: find)
|
|
21
21
|
schema.define_singleton_method(:id_from_object) { |object, type, _context| lookup.id_for(object, type) }
|
|
22
22
|
schema.define_singleton_method(:object_from_id) { |id, context| lookup.object_for(id, context) }
|
|
23
23
|
end
|
|
@@ -26,7 +26,18 @@ module GraphQL
|
|
|
26
26
|
class Lookup
|
|
27
27
|
attr_reader :encoder, :find
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
# Wires the default encoder unless one is given; `use` goes through here.
|
|
30
|
+
def self.build(encoder: nil, find: nil)
|
|
31
|
+
new(encoder: encoder || Encoder.default, find: find)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `new` is inert: with no encoder wired it hands out no ID and honours none. It checks what it is given.
|
|
35
|
+
def initialize(encoder: Encoder::Null, find: nil)
|
|
36
|
+
unless %i[encode decode].all? { |name| encoder.respond_to?(name) }
|
|
37
|
+
raise Encoder::ConfigurationError, "encoder: must answer encode and decode, like an Encoder subclass"
|
|
38
|
+
end
|
|
39
|
+
raise Encoder::ConfigurationError, "find: must be nil or a callable taking the type, the key and the context" unless find.nil? || find.respond_to?(:call)
|
|
40
|
+
|
|
30
41
|
@encoder = encoder
|
|
31
42
|
@find = find
|
|
32
43
|
end
|
|
@@ -74,3 +85,5 @@ module GraphQL
|
|
|
74
85
|
end
|
|
75
86
|
end
|
|
76
87
|
end
|
|
88
|
+
|
|
89
|
+
require "graphql/armory/ids/controls"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module PersistedQueries
|
|
6
|
+
# Test data for persisted queries, reusable by apps and other gems.
|
|
7
|
+
module Controls
|
|
8
|
+
# The operations a client build would persist for Query.example.
|
|
9
|
+
module Operations
|
|
10
|
+
def self.invoices = "query Invoices { invoices }"
|
|
11
|
+
def self.greeting = "query Greeting($name: String!) { greeting(name: $name) }"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A Query type with two persisted operations' fields and a `secret` no operation asks for.
|
|
15
|
+
module Query
|
|
16
|
+
def self.example
|
|
17
|
+
Class.new(GraphQL::Schema::Object) do
|
|
18
|
+
graphql_name "Query"
|
|
19
|
+
field :invoices, [String], null: false
|
|
20
|
+
field :greeting, String, null: false do
|
|
21
|
+
argument :name, String
|
|
22
|
+
end
|
|
23
|
+
field :secret, String, null: false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The root object for Query.example; it adds the name of each field it resolves to `resolved`.
|
|
29
|
+
module Root
|
|
30
|
+
def self.example(resolved = [])
|
|
31
|
+
Class.new do
|
|
32
|
+
define_method(:invoices) { resolved << :invoices; ["A-1"] }
|
|
33
|
+
define_method(:greeting) { |name:| resolved << :greeting; "Hello #{name}" }
|
|
34
|
+
define_method(:secret) { resolved << :secret; "s3cr3t" }
|
|
35
|
+
end.new
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module PersistedQueries
|
|
8
|
+
# What the plugin fetches operations from: a `Manifest`, or any object answering `fetch(id)`. `Null` and
|
|
9
|
+
# `Substitute` are the inert and the test stand-ins.
|
|
10
|
+
module Store
|
|
11
|
+
# The store of a plugin nobody wired: it holds no operation, so every id is unknown.
|
|
12
|
+
class Null
|
|
13
|
+
def fetch(_id)
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Holds the given queries under the SHA-256 of each, like an Apollo manifest, records the ids it was asked
|
|
19
|
+
# for and fails when told: for tests, instead of hand-written stores.
|
|
20
|
+
class Substitute
|
|
21
|
+
attr_reader :fetched
|
|
22
|
+
|
|
23
|
+
def initialize(*queries)
|
|
24
|
+
@manifest = Manifest.new(queries.to_h { |query| [Digest::SHA256.hexdigest(query), query] })
|
|
25
|
+
@fetched = []
|
|
26
|
+
@failure = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Raises `error` from every fetch until `recover`.
|
|
30
|
+
def fail_with(error)
|
|
31
|
+
@failure = error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def recover
|
|
35
|
+
@failure = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def fetch(id)
|
|
39
|
+
@fetched << id
|
|
40
|
+
raise @failure if @failure
|
|
41
|
+
|
|
42
|
+
@manifest.fetch(id)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
gate = Gate.build(store: store, strict: strict)
|
|
44
|
+
|
|
45
|
+
if schema.singleton_class.include?(Guard)
|
|
46
|
+
raise ConfigurationError, "#{schema} already uses PersistedQueries (here or in a parent schema): use it once"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
schema.instance_variable_set(:@armory_persisted_queries, gate)
|
|
50
|
+
schema.singleton_class.prepend(Guard)
|
|
51
|
+
schema.query_class(schema.query_class)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Decides what a request runs: answers `[refusal, document]`, where a refusal is the error message, a
|
|
55
|
+
# document is the stored operation to run instead of the request's text, and `[nil, nil]` runs the request
|
|
56
|
+
# as it came.
|
|
57
|
+
class Gate
|
|
58
|
+
# Wires the app's store with `use`'s defaults; `use` goes through here.
|
|
59
|
+
def self.build(store:, strict: false)
|
|
60
|
+
new(store: store, strict: strict)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `new` is inert: with no store wired it refuses every request. It checks what it is given, so it never
|
|
64
|
+
# lets raw queries through without a store either.
|
|
65
|
+
def initialize(store: Store::Null.new, strict: true)
|
|
66
|
+
if store.is_a?(Hash) || !store.respond_to?(:fetch)
|
|
67
|
+
raise ConfigurationError, "store: must answer fetch(id) with an operation or nil, like a Manifest"
|
|
68
|
+
end
|
|
69
|
+
raise ConfigurationError, "strict: must be true or false, got #{strict.inspect}" unless strict == true || strict == false
|
|
70
|
+
raise ConfigurationError, "strict: false needs a store: with the Null store only raw queries would run" if !strict && store.is_a?(Store::Null)
|
|
71
|
+
|
|
72
|
+
@store = store
|
|
73
|
+
@strict = strict
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def call(context, text, document)
|
|
77
|
+
operation_id = context[:operation_id] if context.respond_to?(:[])
|
|
78
|
+
|
|
79
|
+
if !operation_id.nil?
|
|
80
|
+
operation = operation_id.is_a?(String) ? @store.fetch(operation_id) : nil
|
|
81
|
+
if operation.nil?
|
|
82
|
+
[UNKNOWN_OPERATION, nil]
|
|
83
|
+
elsif document || (text && !same_query?(text, operation))
|
|
84
|
+
[MISMATCH, nil]
|
|
85
|
+
else
|
|
86
|
+
[nil, operation.document]
|
|
87
|
+
end
|
|
88
|
+
elsif @strict && !(context.respond_to?(:[]) && context[:armory_allow_raw_query] == true)
|
|
89
|
+
[RAW_QUERY_REFUSED, nil]
|
|
90
|
+
else
|
|
91
|
+
[nil, nil]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Apollo's clients may send the query with its id; graphql-ruby's subscriptions resend it printed.
|
|
98
|
+
def same_query?(text, operation)
|
|
99
|
+
text == operation.body || text == operation.document.to_query_string
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Keeps the lookup in every query class the schema uses, including one set after `use` or by a child schema.
|
|
104
|
+
module Guard
|
|
105
|
+
UNSET = Object.new.freeze
|
|
106
|
+
|
|
107
|
+
def query_class(new_query_class = UNSET)
|
|
108
|
+
return super() if UNSET.equal?(new_query_class)
|
|
109
|
+
return super(new_query_class) if new_query_class.include?(Lookup)
|
|
110
|
+
|
|
111
|
+
super(Class.new(new_query_class) { include Lookup })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def armory_persisted_queries
|
|
115
|
+
@armory_persisted_queries || superclass.armory_persisted_queries
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The operation id a request sent, from Rack or Rails params: `operationId`, `extensions.operationId` or
|
|
120
|
+
# Apollo's `extensions.persistedQuery.sha256Hash` (`extensions` may arrive as a JSON string on GET).
|
|
121
|
+
# Answers nil when there is none, or when it is not a non-empty String.
|
|
122
|
+
def self.operation_id(params)
|
|
123
|
+
params = plain(params)
|
|
124
|
+
extensions = params["extensions"]
|
|
125
|
+
extensions = parse_json(extensions) if extensions.is_a?(String)
|
|
126
|
+
extensions = plain(extensions)
|
|
127
|
+
persisted_query = plain(extensions["persistedQuery"])
|
|
128
|
+
|
|
129
|
+
[params["operationId"], extensions["operationId"], persisted_query["sha256Hash"]].find do |candidate|
|
|
130
|
+
candidate.is_a?(String) && !candidate.empty?
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.plain(value)
|
|
135
|
+
value = value.to_unsafe_h if value.respond_to?(:to_unsafe_h)
|
|
136
|
+
value.is_a?(Hash) ? value : {}
|
|
137
|
+
end
|
|
138
|
+
private_class_method :plain
|
|
139
|
+
|
|
140
|
+
def self.parse_json(text)
|
|
141
|
+
JSON.parse(text)
|
|
142
|
+
rescue JSON::ParserError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
private_class_method :parse_json
|
|
146
|
+
|
|
147
|
+
# Swaps the request's query for the stored document, or for nothing with an error when it is refused.
|
|
148
|
+
module Lookup
|
|
149
|
+
def initialize(schema, query_string = nil, **options)
|
|
150
|
+
text = query_string || options[:query]
|
|
151
|
+
text = nil if text == ""
|
|
152
|
+
refusal, document = schema.armory_persisted_queries.(options[:context], text, options[:document])
|
|
153
|
+
|
|
154
|
+
return super if refusal.nil? && document.nil?
|
|
155
|
+
return super(schema, nil, **options, query: nil, document: document) if refusal.nil?
|
|
156
|
+
|
|
157
|
+
# A refused request fails validation with the refusal as its only error, so neither engine runs it. Its
|
|
158
|
+
# text is never parsed; a document the app built itself is kept, for `Schema.validate`, which validates it
|
|
159
|
+
# with its own validator.
|
|
160
|
+
super(schema, nil, **options, query: nil, document: options[:document] || EMPTY_DOCUMENT, validate: true, static_validator: Refusal.new(schema, refusal))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
require "graphql/armory/persisted_queries/manifest"
|
|
168
|
+
require "graphql/armory/persisted_queries/store"
|
|
169
|
+
require "graphql/armory/persisted_queries/controls"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module RateLimits
|
|
8
|
+
# Lets each client run at most `limit` operations at the same time:
|
|
9
|
+
#
|
|
10
|
+
# use GraphQL::Armory::RateLimits::Concurrency, store: GraphQL::Armory::RateLimits::MemoryStore.new,
|
|
11
|
+
# limit: 5, soft: false
|
|
12
|
+
#
|
|
13
|
+
# A query analyzer takes a slot before the operation runs, or refuses it before any field resolves; a trace
|
|
14
|
+
# releases every slot its multiplex took when the multiplex ends, even when it raises. A slot a crashed
|
|
15
|
+
# process never released expires after `stale_after` seconds. Soft mode (the default) records but refuses
|
|
16
|
+
# nothing. What happened is in `context[:rate_limit_concurrency]`.
|
|
17
|
+
class Concurrency
|
|
18
|
+
CONTEXT_KEY = :rate_limit_concurrency
|
|
19
|
+
NAMESPACE = :graphql_armory
|
|
20
|
+
|
|
21
|
+
def self.use(schema, store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
|
|
22
|
+
concurrency = build(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
|
|
23
|
+
schema.query_analyzer(Class.new(Analyzer) { define_method(:concurrency) { concurrency } })
|
|
24
|
+
schema.trace_with(Release)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
|
|
28
|
+
def self.build(store:, limit:, stale_after: 60, soft: true, key: nil, on_store_error: nil)
|
|
29
|
+
new(store: store, limit: limit, stale_after: stale_after, soft: soft, key: key, on_store_error: on_store_error)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
|
|
33
|
+
# configuration lets a query through either.
|
|
34
|
+
def initialize(store: Store::Null.new, limit: 1, stale_after: 60, soft: false, key: nil, on_store_error: nil)
|
|
35
|
+
unless Settings.store_answers?(store, acquire: %i[token limit ttl force], release: %i[token])
|
|
36
|
+
raise ConfigurationError, "store: must answer acquire(key, token:, limit:, ttl:, force:) and release(key, token:), like a MemoryStore"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@store = store
|
|
40
|
+
@limit = Settings.wired_limit(limit, store)
|
|
41
|
+
@stale_after = Settings.seconds(:stale_after, stale_after)
|
|
42
|
+
@soft = Settings.wired_soft(soft, store)
|
|
43
|
+
@key = Settings.key_reader(key)
|
|
44
|
+
@on_store_error = Settings.store_error_handler(on_store_error)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Takes a slot for the query: the refusal when none is free, or nil to let it run.
|
|
48
|
+
def call(query)
|
|
49
|
+
unless query.current_trace.is_a?(Release)
|
|
50
|
+
raise ConfigurationError, "this query runs with its own trace (context[:trace]), so Concurrency could never release its slot: remove context[:trace] or add trace_with to that trace"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
key = Settings.key_for(@key, query)
|
|
54
|
+
limit = Settings.limit_for(@limit, key, query)
|
|
55
|
+
if limit.nil?
|
|
56
|
+
record(query, key: key, limit: nil, active: nil, limited: false)
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
token = SecureRandom.hex(16)
|
|
61
|
+
answer = begin
|
|
62
|
+
@store.acquire(key, token: token, limit: limit, ttl: @stale_after, force: @soft)
|
|
63
|
+
rescue StandardError => error
|
|
64
|
+
@on_store_error.call(error, query)
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
acquired, active = Settings.checked(answer, :acquire, "the Integer slots held")
|
|
68
|
+
|
|
69
|
+
# Per query, not in the context hash: queries of a multiplex may share one hash.
|
|
70
|
+
query.context.namespace(NAMESPACE)[:concurrency_slot] = [self, key, token] if acquired || @soft
|
|
71
|
+
record(query, key: key, limit: limit, active: active, limited: !acquired)
|
|
72
|
+
refusal(limit, active) unless acquired || @soft
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def release(query, key, token)
|
|
76
|
+
@store.release(key, token: token)
|
|
77
|
+
rescue StandardError => error
|
|
78
|
+
@on_store_error.call(error, query)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def record(query, **info)
|
|
84
|
+
query.context[CONTEXT_KEY] = { **info.slice(:key, :limit, :active), soft: @soft, limited: info[:limited] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refusal(limit, active)
|
|
88
|
+
GraphQL::AnalysisError.new(
|
|
89
|
+
"Too many operations at the same time: #{active} are already running for this client (limit #{limit}). Retry when one finishes.",
|
|
90
|
+
extensions: { "code" => "RATE_LIMITED", "limit" => limit, "active" => active }
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Takes the slot during analysis, before anything runs.
|
|
95
|
+
class Analyzer < GraphQL::Analysis::Analyzer
|
|
96
|
+
def result
|
|
97
|
+
concurrency.(query)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Releases the slots of every query in the multiplex once it is done, whatever happened. Analysis runs
|
|
102
|
+
# inside `execute_multiplex` on both engines, so every slot taken is released here.
|
|
103
|
+
module Release
|
|
104
|
+
def execute_multiplex(multiplex:)
|
|
105
|
+
super
|
|
106
|
+
ensure
|
|
107
|
+
# Every slot is released even if one release raises (an on_store_error that raises); the first such
|
|
108
|
+
# error is raised afterwards.
|
|
109
|
+
failure = nil
|
|
110
|
+
multiplex.queries.each do |query|
|
|
111
|
+
slots = query.context.namespace(NAMESPACE)
|
|
112
|
+
concurrency, key, token = slots.delete(:concurrency_slot)
|
|
113
|
+
next unless concurrency
|
|
114
|
+
|
|
115
|
+
begin
|
|
116
|
+
concurrency.release(query, key, token)
|
|
117
|
+
rescue StandardError => error
|
|
118
|
+
failure ||= error
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
raise failure if failure
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module RateLimits
|
|
6
|
+
# Test data for the rate limiters, reusable by apps and other gems.
|
|
7
|
+
module Controls
|
|
8
|
+
# A Query type: `cheap` costs 1 point, `report` costs 40, `broken` raises when resolved.
|
|
9
|
+
module Query
|
|
10
|
+
def self.example
|
|
11
|
+
Class.new(GraphQL::Schema::Object) do
|
|
12
|
+
graphql_name "Query"
|
|
13
|
+
field :cheap, String, null: false
|
|
14
|
+
field :report, String, null: false, complexity: 40
|
|
15
|
+
field :broken, String, null: false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The root object for Query.example; it adds the name of each field it resolves to `resolved`.
|
|
21
|
+
module Root
|
|
22
|
+
def self.example(resolved = [])
|
|
23
|
+
Class.new do
|
|
24
|
+
define_method(:cheap) { resolved << :cheap; "ok" }
|
|
25
|
+
define_method(:report) { resolved << :report; "big" }
|
|
26
|
+
define_method(:broken) { raise IOError, "disk on fire" }
|
|
27
|
+
end.new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A query context naming its client.
|
|
32
|
+
module Context
|
|
33
|
+
def self.example(key: "user:1", **context)
|
|
34
|
+
{ RateLimits::KEY => key, **context }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module RateLimits
|
|
6
|
+
# Buckets kept in this process's memory: for tests, development and single-process servers. Several
|
|
7
|
+
# processes each keep their own buckets, so a client gets the limit once per process; share a store between
|
|
8
|
+
# processes in production.
|
|
9
|
+
class MemoryStore
|
|
10
|
+
def self.monotonic_clock
|
|
11
|
+
-> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(clock: self.class.monotonic_clock)
|
|
15
|
+
@clock = clock
|
|
16
|
+
@buckets = {}
|
|
17
|
+
@slots = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Token bucket: holds up to `limit` points and refills `limit` points every `window` seconds, continuously.
|
|
22
|
+
# Takes `cost` points when they are there; with `force: true` takes them anyway, down to zero. Answers
|
|
23
|
+
# whether they were there and the whole points left.
|
|
24
|
+
def take(key, cost:, limit:, window:, force: false)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
now = @clock.call
|
|
27
|
+
level, at = @buckets.fetch(key) { [limit, now] }
|
|
28
|
+
level = [level + ([now - at, 0].max * limit / window.to_f), limit].min
|
|
29
|
+
|
|
30
|
+
taken = cost <= level
|
|
31
|
+
level = [level - cost, 0].max if taken || force
|
|
32
|
+
@buckets[key] = [level, [now, at].max]
|
|
33
|
+
|
|
34
|
+
[taken, level.floor]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Slots for operations running at the same time: at most `limit` per key, each held by a token until
|
|
39
|
+
# released or until `ttl` seconds pass (a process that crashed never releases). Takes a slot when one is
|
|
40
|
+
# free; with `force: true` takes it anyway. Answers whether one was free and the slots now held.
|
|
41
|
+
def acquire(key, token:, limit:, ttl:, force: false)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
now = @clock.call
|
|
44
|
+
slots = (@slots[key] ||= {})
|
|
45
|
+
slots.delete_if { |_token, expires_at| expires_at <= now }
|
|
46
|
+
|
|
47
|
+
acquired = slots.size < limit
|
|
48
|
+
slots[token] = now + ttl if acquired || force
|
|
49
|
+
|
|
50
|
+
[acquired, slots.size]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def release(key, token:)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
slots = @slots[key] or return
|
|
57
|
+
slots.delete(token)
|
|
58
|
+
@slots.delete(key) if slots.empty?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|