graphql-armory 0.1.0.pre4 → 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 +11 -0
- data/README.md +4 -4
- data/docs/encrypted-ids.md +4 -0
- data/docs/persisted-queries.md +4 -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/store.rb +48 -0
- data/lib/graphql/armory/persisted_queries.rb +54 -29
- 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 +1 -0
- metadata +14 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module RateLimits
|
|
8
|
+
# What a limiter keeps its state in: `MemoryStore`, `RedisStore`, or any object answering the same methods.
|
|
9
|
+
# `Null` and `Substitute` are the inert and the test stand-ins.
|
|
10
|
+
module Store
|
|
11
|
+
# The store of a limiter nobody wired: it refuses every charge and slot, so `Points.new` and
|
|
12
|
+
# `Concurrency.new` never let a query through.
|
|
13
|
+
class Null
|
|
14
|
+
def take(_key, cost:, limit:, window:, force: false)
|
|
15
|
+
[false, 0]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def acquire(_key, token:, limit:, ttl:, force: false)
|
|
19
|
+
[false, 0]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def release(_key, token:)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A MemoryStore on a clock the test moves (`now`), that records its calls, fails when told and answers what
|
|
28
|
+
# it is told: for tests, instead of hand-written fakes.
|
|
29
|
+
class Substitute
|
|
30
|
+
attr_accessor :now
|
|
31
|
+
attr_reader :calls
|
|
32
|
+
|
|
33
|
+
def initialize(now: 0.0)
|
|
34
|
+
@now = now
|
|
35
|
+
@memory = MemoryStore.new(clock: -> { @now })
|
|
36
|
+
@calls = []
|
|
37
|
+
@failures = {}
|
|
38
|
+
@answers = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raises `error` from `on` (:take, :acquire, :release, or :all) for the next `times` calls, or every call.
|
|
42
|
+
def fail_with(error, on: :all, times: nil)
|
|
43
|
+
raise ArgumentError, "on: must be :take, :acquire, :release or :all, got #{on.inspect}" unless %i[take acquire release all].include?(on)
|
|
44
|
+
raise ArgumentError, "times: must be nil (every call) or a positive Integer, got #{times.inspect}" unless times.nil? || (times.is_a?(Integer) && times.positive?)
|
|
45
|
+
|
|
46
|
+
@failures[on] = [error, times]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def recover
|
|
50
|
+
@failures.clear
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Answers `value` from `method` instead of the real answer, for answers no real store gives.
|
|
54
|
+
def answer(method, value)
|
|
55
|
+
@answers[method] = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Holds a slot for an operation running elsewhere. Not recorded in `calls`.
|
|
59
|
+
def occupy(key, token: SecureRandom.hex(8), ttl: 60)
|
|
60
|
+
@memory.acquire(key, token: token, limit: 1, ttl: ttl, force: true)
|
|
61
|
+
token
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The slots held for `key` now. Not recorded in `calls`.
|
|
65
|
+
def held(key)
|
|
66
|
+
probe = SecureRandom.hex(8)
|
|
67
|
+
held = @memory.acquire(key, token: probe, limit: 1, ttl: 1, force: true).last - 1
|
|
68
|
+
@memory.release(key, token: probe)
|
|
69
|
+
held
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def take(key, cost:, limit:, window:, force: false)
|
|
73
|
+
perform(:take, key, cost: cost, limit: limit, window: window, force: force) do
|
|
74
|
+
@memory.take(key, cost: cost, limit: limit, window: window, force: force)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def acquire(key, token:, limit:, ttl:, force: false)
|
|
79
|
+
perform(:acquire, key, token: token, limit: limit, ttl: ttl, force: force) do
|
|
80
|
+
@memory.acquire(key, token: token, limit: limit, ttl: ttl, force: force)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def release(key, token:)
|
|
85
|
+
perform(:release, key, token: token) { @memory.release(key, token: token) }
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def perform(method, key, **arguments)
|
|
92
|
+
@calls << [method, key, arguments]
|
|
93
|
+
fail!(method)
|
|
94
|
+
return @answers[method] if @answers.key?(method)
|
|
95
|
+
|
|
96
|
+
yield
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def fail!(method)
|
|
100
|
+
on = @failures.key?(method) ? method : :all
|
|
101
|
+
error, times = @failures[on]
|
|
102
|
+
return unless error
|
|
103
|
+
|
|
104
|
+
if times
|
|
105
|
+
times -= 1
|
|
106
|
+
times.zero? ? @failures.delete(on) : @failures[on] = [error, times]
|
|
107
|
+
end
|
|
108
|
+
raise error
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
# Rate limits built for GraphQL, where requests differ wildly in cost. Each client is named by
|
|
6
|
+
# `context[:rate_limit_key]` (or a `key:` callable) and limited through a store: `MemoryStore` for tests and
|
|
7
|
+
# a single process, a shared store for several processes.
|
|
8
|
+
module RateLimits
|
|
9
|
+
class ConfigurationError < Armory::Error; end
|
|
10
|
+
|
|
11
|
+
KEY = :rate_limit_key
|
|
12
|
+
|
|
13
|
+
# The settings every limiter shares, checked at boot.
|
|
14
|
+
module Settings
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def key_reader(key)
|
|
18
|
+
return ->(query) { query.context[KEY] } if key.nil?
|
|
19
|
+
raise ConfigurationError, "key: must be a callable that takes the query, got #{key.inspect}" unless key.respond_to?(:call)
|
|
20
|
+
|
|
21
|
+
key
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def key_for(reader, query)
|
|
25
|
+
key = reader.call(query)
|
|
26
|
+
return key if key.is_a?(String) && !key.empty?
|
|
27
|
+
|
|
28
|
+
raise ConfigurationError, "no client key for this query: put a non-empty String in context[:#{KEY}], or give the limiter a key: callable"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def soft(value)
|
|
32
|
+
raise ConfigurationError, "soft: must be true or false, got #{value.inspect}" unless value == true || value == false
|
|
33
|
+
|
|
34
|
+
value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A limit callable may answer nil (no limit for that key), so on the Null store it could let queries through.
|
|
38
|
+
def wired_limit(value, store)
|
|
39
|
+
limit(value)
|
|
40
|
+
raise ConfigurationError, "a limit: callable needs a store: on the Null store a nil answer would let queries run unlimited" if value.respond_to?(:call) && store.is_a?(Store::Null)
|
|
41
|
+
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Soft mode counts but never refuses, so on the Null store it would let everything through uncounted.
|
|
46
|
+
def wired_soft(value, store)
|
|
47
|
+
soft(value)
|
|
48
|
+
raise ConfigurationError, "soft: true needs a store: the Null store counts nothing, so every query would run unlimited" if value && store.is_a?(Store::Null)
|
|
49
|
+
|
|
50
|
+
value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def limit(limit)
|
|
54
|
+
return limit if limit.respond_to?(:call) || positive_integer?(limit)
|
|
55
|
+
|
|
56
|
+
raise ConfigurationError, "limit: must be a positive Integer, or a callable taking the key and the query, got #{limit.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def limit_for(limit, key, query)
|
|
60
|
+
return limit unless limit.respond_to?(:call)
|
|
61
|
+
|
|
62
|
+
answer = limit.call(key, query)
|
|
63
|
+
return answer if answer.nil? || positive_integer?(answer)
|
|
64
|
+
|
|
65
|
+
raise ConfigurationError, "limit: answered #{answer.inspect} for #{key}: it must answer a positive Integer, or nil for no limit"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def positive_integer?(value)
|
|
69
|
+
value.is_a?(Integer) && value.positive?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def seconds(name, value)
|
|
73
|
+
return value if value.is_a?(Numeric) && value.positive? && value.finite?
|
|
74
|
+
|
|
75
|
+
raise ConfigurationError, "#{name}: must be a positive number of seconds, got #{value.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Whether `store` answers each method with the keywords this gem passes, so a wrong signature fails at
|
|
79
|
+
# boot instead of failing open on every query.
|
|
80
|
+
def store_answers?(store, methods)
|
|
81
|
+
methods.all? do |name, keywords|
|
|
82
|
+
next false unless store.respond_to?(name)
|
|
83
|
+
|
|
84
|
+
parameters = store.method(name).parameters
|
|
85
|
+
next false unless parameters.any? { |type, _| %i[req opt rest].include?(type) } # the key
|
|
86
|
+
next true if parameters.any? { |type, _| type == :keyrest }
|
|
87
|
+
|
|
88
|
+
(keywords - parameters.select { |type, _| type == :key || type == :keyreq }.map(&:last)).empty?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A store answer that is not [true or false, a whole number] would let queries through: 0 is truthy.
|
|
93
|
+
def checked(answer, method, meaning)
|
|
94
|
+
granted, count = answer if answer.is_a?(Array) && answer.size == 2
|
|
95
|
+
return answer if (granted == true || granted == false) && count.is_a?(Integer) && !count.negative?
|
|
96
|
+
|
|
97
|
+
raise ConfigurationError, "store: #{method} answered #{answer.inspect}: it must answer [true or false, #{meaning}]"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def store_error_handler(handler)
|
|
101
|
+
return ->(error, _query) { warn("graphql-armory rate limit store failed, query let through: #{error.class}: #{error.message}") } if handler.nil?
|
|
102
|
+
raise ConfigurationError, "on_store_error: must be a callable that takes the error and the query" unless handler.respond_to?(:call)
|
|
103
|
+
|
|
104
|
+
handler
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
require "graphql/armory/rate_limits/memory_store"
|
|
112
|
+
require "graphql/armory/rate_limits/store"
|
|
113
|
+
require "graphql/armory/rate_limits/redis_store"
|
|
114
|
+
require "graphql/armory/rate_limits/points"
|
|
115
|
+
require "graphql/armory/rate_limits/concurrency"
|
|
116
|
+
require "graphql/armory/rate_limits/controls"
|
data/lib/graphql/armory.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: graphql-armory
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.
|
|
4
|
+
version: 0.1.0.pre5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Israel
|
|
@@ -44,6 +44,7 @@ files:
|
|
|
44
44
|
- docs/authorization.md
|
|
45
45
|
- docs/encrypted-ids.md
|
|
46
46
|
- docs/persisted-queries.md
|
|
47
|
+
- docs/rate-limiting.md
|
|
47
48
|
- lib/graphql-armory.rb
|
|
48
49
|
- lib/graphql/armory.rb
|
|
49
50
|
- lib/graphql/armory/authorization.rb
|
|
@@ -78,10 +79,22 @@ files:
|
|
|
78
79
|
- lib/graphql/armory/encoder.rb
|
|
79
80
|
- lib/graphql/armory/encoder/default.rb
|
|
80
81
|
- lib/graphql/armory/encoder/legacy.rb
|
|
82
|
+
- lib/graphql/armory/encoder/null.rb
|
|
83
|
+
- lib/graphql/armory/encoder/substitute.rb
|
|
81
84
|
- lib/graphql/armory/encoder/versioned.rb
|
|
82
85
|
- lib/graphql/armory/ids.rb
|
|
86
|
+
- lib/graphql/armory/ids/controls.rb
|
|
83
87
|
- lib/graphql/armory/persisted_queries.rb
|
|
88
|
+
- lib/graphql/armory/persisted_queries/controls.rb
|
|
84
89
|
- lib/graphql/armory/persisted_queries/manifest.rb
|
|
90
|
+
- lib/graphql/armory/persisted_queries/store.rb
|
|
91
|
+
- lib/graphql/armory/rate_limits.rb
|
|
92
|
+
- lib/graphql/armory/rate_limits/concurrency.rb
|
|
93
|
+
- lib/graphql/armory/rate_limits/controls.rb
|
|
94
|
+
- lib/graphql/armory/rate_limits/memory_store.rb
|
|
95
|
+
- lib/graphql/armory/rate_limits/points.rb
|
|
96
|
+
- lib/graphql/armory/rate_limits/redis_store.rb
|
|
97
|
+
- lib/graphql/armory/rate_limits/store.rb
|
|
85
98
|
- lib/graphql/armory/version.rb
|
|
86
99
|
- lib/tasks/graphql_armory.rake
|
|
87
100
|
homepage: https://github.com/israelb/graphql-armory
|