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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module RateLimits
|
|
6
|
+
# Charges each query its complexity, as graphql-ruby computes it before running it, against a bucket of
|
|
7
|
+
# points per client that refills over a window:
|
|
8
|
+
#
|
|
9
|
+
# use GraphQL::Armory::RateLimits::Points, store: GraphQL::Armory::RateLimits::MemoryStore.new,
|
|
10
|
+
# limit: 1_000, window: 60, soft: false
|
|
11
|
+
#
|
|
12
|
+
# A query that costs more than the client has left is refused before any field resolves, and charged
|
|
13
|
+
# nothing. Soft mode (the default) charges and records but refuses nothing. What happened is in
|
|
14
|
+
# `context[:rate_limit_points]`.
|
|
15
|
+
class Points
|
|
16
|
+
CONTEXT_KEY = :rate_limit_points
|
|
17
|
+
NAMESPACE = :graphql_armory
|
|
18
|
+
|
|
19
|
+
def self.use(schema, store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
|
|
20
|
+
points = build(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
|
|
21
|
+
schema.query_analyzer(Class.new(Analyzer) { define_method(:points) { points } })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# HTTP headers for the budget a result recorded: `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`
|
|
25
|
+
# (seconds until the bucket is full again). Empty when the query had no limit.
|
|
26
|
+
def self.headers(result)
|
|
27
|
+
info = result.context.namespace(NAMESPACE)[:points]
|
|
28
|
+
return {} unless info && info[:limit]
|
|
29
|
+
|
|
30
|
+
{ "RateLimit-Limit" => info[:limit].to_s, "RateLimit-Remaining" => info[:remaining].to_s, "RateLimit-Reset" => reset_in(info).to_s }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.reset_in(info)
|
|
34
|
+
((info[:limit] - info[:remaining]) * info[:window] / info[:limit].to_f).ceil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Wires the app's store with `use`'s defaults (soft mode); `use` goes through here.
|
|
38
|
+
def self.build(store:, limit:, window: 60, soft: true, key: nil, on_store_error: nil, extensions: false)
|
|
39
|
+
new(store: store, limit: limit, window: window, soft: soft, key: key, on_store_error: on_store_error, extensions: extensions)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `new` is inert: with no store wired it refuses every query. It checks every setting, so no partial
|
|
43
|
+
# configuration lets a query through either.
|
|
44
|
+
def initialize(store: Store::Null.new, limit: 1, window: 60, soft: false, key: nil, on_store_error: nil, extensions: false)
|
|
45
|
+
unless Settings.store_answers?(store, take: %i[cost limit window force])
|
|
46
|
+
raise ConfigurationError, "store: must answer take(key, cost:, limit:, window:, force:), like a MemoryStore"
|
|
47
|
+
end
|
|
48
|
+
raise ConfigurationError, "extensions: must be true or false, got #{extensions.inspect}" unless extensions == true || extensions == false
|
|
49
|
+
|
|
50
|
+
@store = store
|
|
51
|
+
@limit = Settings.wired_limit(limit, store)
|
|
52
|
+
@window = Settings.seconds(:window, window)
|
|
53
|
+
@soft = Settings.wired_soft(soft, store)
|
|
54
|
+
@key = Settings.key_reader(key)
|
|
55
|
+
@on_store_error = Settings.store_error_handler(on_store_error)
|
|
56
|
+
@extensions = extensions
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The refusal for a query that costs `cost`, or nil to let it run.
|
|
60
|
+
def call(query, cost)
|
|
61
|
+
key = Settings.key_for(@key, query)
|
|
62
|
+
limit = Settings.limit_for(@limit, key, query)
|
|
63
|
+
if limit.nil?
|
|
64
|
+
record(query, key: key, cost: cost, limit: nil, remaining: nil, limited: false)
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
answer = begin
|
|
69
|
+
@store.take(key, cost: cost, limit: limit, window: @window, force: @soft)
|
|
70
|
+
rescue StandardError => error
|
|
71
|
+
# No record: a context hash shared with an earlier query must not keep its numbers.
|
|
72
|
+
query.context[CONTEXT_KEY] = nil
|
|
73
|
+
@on_store_error.call(error, query)
|
|
74
|
+
return nil
|
|
75
|
+
end
|
|
76
|
+
taken, remaining = Settings.checked(answer, :take, "the Integer points left")
|
|
77
|
+
|
|
78
|
+
info = record(query, key: key, cost: cost, limit: limit, remaining: remaining, limited: !taken)
|
|
79
|
+
if @extensions
|
|
80
|
+
query.context.response_extensions["rateLimit"] =
|
|
81
|
+
{ "cost" => cost, "limit" => limit, "remaining" => remaining, "resetIn" => self.class.reset_in(info) }
|
|
82
|
+
end
|
|
83
|
+
refusal(cost, limit, remaining) unless taken || @soft
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# In the context hash for instrumentation, and per query for `headers`: queries of a multiplex may share one
|
|
89
|
+
# context hash, where the last query's record wins.
|
|
90
|
+
def record(query, **info)
|
|
91
|
+
info = { **info.slice(:key, :cost, :limit, :remaining), window: @window, soft: @soft, limited: info[:limited] }
|
|
92
|
+
query.context.namespace(NAMESPACE)[:points] = info
|
|
93
|
+
query.context[CONTEXT_KEY] = info
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def refusal(cost, limit, remaining)
|
|
97
|
+
if cost > limit
|
|
98
|
+
return GraphQL::AnalysisError.new(
|
|
99
|
+
"Rate limit exceeded: this query costs #{cost} points and the limit is #{limit} every #{@window} seconds, so it can never run.",
|
|
100
|
+
extensions: { "code" => "RATE_LIMITED", "cost" => cost, "limit" => limit }
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
retry_after = ((cost - remaining) * @window / limit.to_f).ceil
|
|
105
|
+
GraphQL::AnalysisError.new(
|
|
106
|
+
"Rate limit exceeded: this query costs #{cost} points and #{remaining} remain (#{limit} every #{@window} seconds). Retry in #{retry_after} seconds.",
|
|
107
|
+
extensions: { "code" => "RATE_LIMITED", "cost" => cost, "remaining" => remaining, "retryAfter" => retry_after }
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# graphql-ruby's own complexity analysis, then the charge.
|
|
112
|
+
class Analyzer < GraphQL::Analysis::QueryComplexity
|
|
113
|
+
def result
|
|
114
|
+
cost = super
|
|
115
|
+
unless cost.is_a?(Numeric) && cost.finite? && !cost.negative?
|
|
116
|
+
raise ConfigurationError, "the query complexity is #{cost.inspect}: field complexities must be numbers of zero or more"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fractional complexities (`complexity: 0.5`) are charged whole points, rounded up.
|
|
120
|
+
points.(query, cost.ceil)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module RateLimits
|
|
8
|
+
# Buckets and slots kept in Redis, shared by every process of the app: the store for production.
|
|
9
|
+
#
|
|
10
|
+
# store = GraphQL::Armory::RateLimits::RedisStore.new(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
|
|
11
|
+
#
|
|
12
|
+
# Each change is one Lua script, so concurrent requests never race, and the time comes from Redis's own clock,
|
|
13
|
+
# so processes on different machines agree on it. Keys live under `prefix` and expire once they no longer
|
|
14
|
+
# matter. `redis:` is a redis-rb client (>= 4.8), or anything answering `eval` and `evalsha` the same way;
|
|
15
|
+
# its errors reach the limiters, which let the query through and call `on_store_error`. This gem does not
|
|
16
|
+
# depend on the redis gem: the app adds it.
|
|
17
|
+
class RedisStore
|
|
18
|
+
DEFAULT_PREFIX = "graphql-armory:rate-limits:"
|
|
19
|
+
|
|
20
|
+
# The time in seconds: the one given by the caller (tests), or Redis's own clock.
|
|
21
|
+
CLOCK = <<~LUA
|
|
22
|
+
local function clock(given)
|
|
23
|
+
if given ~= "" then return tonumber(given) end
|
|
24
|
+
local time = redis.call("TIME")
|
|
25
|
+
return tonumber(time[1]) + tonumber(time[2]) / 1000000
|
|
26
|
+
end
|
|
27
|
+
LUA
|
|
28
|
+
|
|
29
|
+
# Same token bucket as MemoryStore#take. The bucket expires once a whole window has passed: it is full
|
|
30
|
+
# again by then, the same as no bucket at all. An expiry is only ever pushed later, never brought closer, so
|
|
31
|
+
# a limiter with a shorter window sharing the key cannot cut it short. The points left go back as a string:
|
|
32
|
+
# Redis would truncate a Lua number past 2**63. A bucket whose fields are not numbers is an error, so the
|
|
33
|
+
# limiter calls on_store_error instead of reading it as full.
|
|
34
|
+
TAKE = <<~LUA
|
|
35
|
+
#{CLOCK}
|
|
36
|
+
local cost, limit, window = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
|
|
37
|
+
local force, now = ARGV[4] == "1", clock(ARGV[5])
|
|
38
|
+
local state = redis.call("HMGET", KEYS[1], "level", "at")
|
|
39
|
+
local level, at = tonumber(state[1]), tonumber(state[2])
|
|
40
|
+
if not (state[1] or state[2]) then
|
|
41
|
+
level, at = limit, now
|
|
42
|
+
elseif not (level and at) or level ~= level or at ~= at or level < 0 or level == math.huge or math.abs(at) == math.huge then
|
|
43
|
+
return redis.error_reply("graphql-armory: corrupt rate limit bucket at " .. KEYS[1])
|
|
44
|
+
end
|
|
45
|
+
level = math.min(level + math.max(now - at, 0) * limit / window, limit)
|
|
46
|
+
|
|
47
|
+
local taken = cost <= level
|
|
48
|
+
if taken or force then level = math.max(level - cost, 0) end
|
|
49
|
+
redis.call("HSET", KEYS[1], "level", string.format("%.17g", level), "at", string.format("%.17g", math.max(now, at)))
|
|
50
|
+
local keep = math.ceil(window * 1000) + 1000
|
|
51
|
+
if redis.call("PTTL", KEYS[1]) < keep then redis.call("PEXPIRE", KEYS[1], keep) end
|
|
52
|
+
|
|
53
|
+
return { taken and 1 or 0, string.format("%.0f", math.floor(level)) }
|
|
54
|
+
LUA
|
|
55
|
+
|
|
56
|
+
# Same slots as MemoryStore#acquire: a sorted set of tokens scored by when they expire. The key lives as long
|
|
57
|
+
# as its latest slot, whatever ttl each acquire passed.
|
|
58
|
+
ACQUIRE = <<~LUA
|
|
59
|
+
#{CLOCK}
|
|
60
|
+
local token, limit, ttl = ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
|
|
61
|
+
local force, now = ARGV[4] == "1", clock(ARGV[5])
|
|
62
|
+
redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", string.format("%.17g", now))
|
|
63
|
+
|
|
64
|
+
local acquired = redis.call("ZCARD", KEYS[1]) < limit
|
|
65
|
+
if acquired or force then
|
|
66
|
+
redis.call("ZADD", KEYS[1], string.format("%.17g", now + ttl), token)
|
|
67
|
+
local latest = tonumber(redis.call("ZRANGE", KEYS[1], -1, -1, "WITHSCORES")[2])
|
|
68
|
+
redis.call("PEXPIRE", KEYS[1], math.ceil((latest - now) * 1000) + 1000)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return { acquired and 1 or 0, redis.call("ZCARD", KEYS[1]) }
|
|
72
|
+
LUA
|
|
73
|
+
|
|
74
|
+
RELEASE = <<~LUA
|
|
75
|
+
return redis.call("ZREM", KEYS[1], ARGV[1])
|
|
76
|
+
LUA
|
|
77
|
+
|
|
78
|
+
def initialize(redis:, prefix: DEFAULT_PREFIX, clock: nil)
|
|
79
|
+
unless redis.respond_to?(:eval) && redis.respond_to?(:evalsha)
|
|
80
|
+
raise ConfigurationError, "redis: must be a Redis client that runs scripts (eval and evalsha), like Redis.new from the redis gem"
|
|
81
|
+
end
|
|
82
|
+
raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
|
|
83
|
+
raise ConfigurationError, "clock: must be nil (Redis's clock) or answer call with the time in seconds" unless clock.nil? || clock.respond_to?(:call)
|
|
84
|
+
|
|
85
|
+
@redis = redis
|
|
86
|
+
@prefix = prefix
|
|
87
|
+
@clock = clock
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def take(key, cost:, limit:, window:, force: false)
|
|
91
|
+
taken, level = script(TAKE, "#{@prefix}points:#{key}", cost, limit, seconds(window), flag(force), now)
|
|
92
|
+
[taken == 1, Integer(level, 10)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def acquire(key, token:, limit:, ttl:, force: false)
|
|
96
|
+
acquired, held = script(ACQUIRE, "#{@prefix}slots:#{key}", token, limit, seconds(ttl), flag(force), now)
|
|
97
|
+
[acquired == 1, held]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def release(key, token:)
|
|
101
|
+
script(RELEASE, "#{@prefix}slots:#{key}", token)
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def flag(value)
|
|
108
|
+
value ? "1" : "0"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Lua reads decimal numbers only: not "1/2" (a Rational) nor a Time.
|
|
112
|
+
def seconds(value)
|
|
113
|
+
Float(value).to_s
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def now
|
|
117
|
+
@clock ? seconds(@clock.call.to_f) : ""
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# EVALSHA sends only the script's digest; Redis forgets scripts on restart or SCRIPT FLUSH, and then EVAL
|
|
121
|
+
# sends it whole once more.
|
|
122
|
+
def script(source, key, *argv)
|
|
123
|
+
argv = argv.map(&:to_s)
|
|
124
|
+
@redis.evalsha(Digest::SHA1.hexdigest(source), keys: [key], argv: argv)
|
|
125
|
+
rescue StandardError => error
|
|
126
|
+
raise unless error.message.start_with?("NOSCRIPT")
|
|
127
|
+
|
|
128
|
+
@redis.eval(source, keys: [key], argv: argv)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -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
|
|
@@ -43,6 +43,8 @@ files:
|
|
|
43
43
|
- README.md
|
|
44
44
|
- docs/authorization.md
|
|
45
45
|
- docs/encrypted-ids.md
|
|
46
|
+
- docs/persisted-queries.md
|
|
47
|
+
- docs/rate-limiting.md
|
|
46
48
|
- lib/graphql-armory.rb
|
|
47
49
|
- lib/graphql/armory.rb
|
|
48
50
|
- lib/graphql/armory/authorization.rb
|
|
@@ -77,8 +79,22 @@ files:
|
|
|
77
79
|
- lib/graphql/armory/encoder.rb
|
|
78
80
|
- lib/graphql/armory/encoder/default.rb
|
|
79
81
|
- lib/graphql/armory/encoder/legacy.rb
|
|
82
|
+
- lib/graphql/armory/encoder/null.rb
|
|
83
|
+
- lib/graphql/armory/encoder/substitute.rb
|
|
80
84
|
- lib/graphql/armory/encoder/versioned.rb
|
|
81
85
|
- lib/graphql/armory/ids.rb
|
|
86
|
+
- lib/graphql/armory/ids/controls.rb
|
|
87
|
+
- lib/graphql/armory/persisted_queries.rb
|
|
88
|
+
- lib/graphql/armory/persisted_queries/controls.rb
|
|
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
|
|
82
98
|
- lib/graphql/armory/version.rb
|
|
83
99
|
- lib/tasks/graphql_armory.rake
|
|
84
100
|
homepage: https://github.com/israelb/graphql-armory
|