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.
@@ -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
@@ -17,7 +17,7 @@ module GraphQL
17
17
  SEPARATOR = "/"
18
18
 
19
19
  def self.use(schema, encoder: nil, find: nil)
20
- lookup = Lookup.new(encoder: encoder || Encoder.default, find: find)
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
- def initialize(encoder:, find: nil)
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