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.
@@ -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.pre4"
5
+ VERSION = "0.1.0.pre5"
6
6
  end
7
7
  end
@@ -14,3 +14,4 @@ require "graphql/armory/encoder"
14
14
  require "graphql/armory/authorization"
15
15
  require "graphql/armory/ids"
16
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.pre4
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