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,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"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Armory
5
- VERSION = "0.1.0.pre3"
5
+ VERSION = "0.1.0.pre5"
6
6
  end
7
7
  end
@@ -13,3 +13,5 @@ end
13
13
  require "graphql/armory/encoder"
14
14
  require "graphql/armory/authorization"
15
15
  require "graphql/armory/ids"
16
+ require "graphql/armory/persisted_queries"
17
+ require "graphql/armory/rate_limits"
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.pre3
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