graphql-armory 0.1.0.pre8 → 0.1.0.pre9
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 +47 -0
- data/README.md +5 -5
- data/docs/authorization.md +88 -5
- data/docs/encrypted-ids.md +1 -1
- data/docs/persisted-queries.md +111 -19
- data/docs/rate-limiting.md +32 -11
- data/docs/telemetry.md +31 -8
- data/exe/graphql-armory-publish +6 -0
- data/lib/graphql/armory/authorization/adapters/cancan.rb +7 -3
- data/lib/graphql/armory/authorization/adapters/pundit.rb +8 -1
- data/lib/graphql/armory/authorization/audit/baseline.rb +191 -0
- data/lib/graphql/armory/authorization/audit/command.rb +57 -8
- data/lib/graphql/armory/authorization/audit/findings.rb +3 -2
- data/lib/graphql/armory/authorization/audit/interface_rules.rb +4 -1
- data/lib/graphql/armory/authorization/audit/observed_rules.rb +58 -0
- data/lib/graphql/armory/authorization/audit/payload_reuse.rb +95 -0
- data/lib/graphql/armory/authorization/audit/plain_resolvers.rb +85 -0
- data/lib/graphql/armory/authorization/audit/reach.rb +75 -0
- data/lib/graphql/armory/authorization/audit/report.rb +7 -1
- data/lib/graphql/armory/authorization/audit/type_rules.rb +19 -2
- data/lib/graphql/armory/authorization/audit.rb +12 -4
- data/lib/graphql/armory/authorization/authorize.rb +28 -8
- data/lib/graphql/armory/authorization/configuration.rb +4 -3
- data/lib/graphql/armory/authorization/definitions.rb +7 -0
- data/lib/graphql/armory/authorization/integration/field.rb +26 -4
- data/lib/graphql/armory/authorization/integration/object.rb +8 -0
- data/lib/graphql/armory/authorization/integration/resolver.rb +2 -0
- data/lib/graphql/armory/authorization/integration.rb +14 -3
- data/lib/graphql/armory/authorization/rspec.rb +10 -4
- data/lib/graphql/armory/authorization/rule.rb +19 -3
- data/lib/graphql/armory/authorization/scope.rb +6 -6
- data/lib/graphql/armory/authorization.rb +1 -1
- data/lib/graphql/armory/ids.rb +11 -11
- data/lib/graphql/armory/persisted_queries/manifest.rb +2 -0
- data/lib/graphql/armory/persisted_queries/manifests.rb +69 -0
- data/lib/graphql/armory/persisted_queries/publisher.rb +127 -0
- data/lib/graphql/armory/persisted_queries/redis_store.rb +137 -0
- data/lib/graphql/armory/persisted_queries.rb +55 -16
- data/lib/graphql/armory/railtie.rb +10 -0
- data/lib/graphql/armory/rate_limits/analysis_guard.rb +79 -0
- data/lib/graphql/armory/rate_limits/concurrency.rb +91 -14
- data/lib/graphql/armory/rate_limits/points.rb +91 -14
- data/lib/graphql/armory/rate_limits/time.rb +107 -19
- data/lib/graphql/armory/rate_limits.rb +29 -5
- data/lib/graphql/armory/telemetry.rb +71 -9
- data/lib/graphql/armory/version.rb +1 -1
- data/lib/graphql/armory.rb +1 -0
- data/lib/tasks/graphql_armory.rake +11 -4
- metadata +15 -3
|
@@ -41,7 +41,7 @@ module GraphQL
|
|
|
41
41
|
return Adapters::Null.new.scope(nil, items, rule) if rule.nil?
|
|
42
42
|
|
|
43
43
|
configuration = configuration(context)
|
|
44
|
-
Scope.(items, rule, context, adapter: configuration.adapter, sinks: configuration.sinks)
|
|
44
|
+
Scope.(items, rule, context, adapter: configuration.adapter, sinks: configuration.sinks, on_sink_error: configuration.on_sink_error)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# Looks an object up inside the user's scope, by primary key unless a key is given.
|
|
@@ -60,7 +60,7 @@ module GraphQL
|
|
|
60
60
|
|
|
61
61
|
# An in-memory scope yields an Array, which cannot look one record up: find it, then ask the rule.
|
|
62
62
|
record = model.find_by(conditions)
|
|
63
|
-
record if record && allowed?(record, rule, context)
|
|
63
|
+
record if record && allowed?(record, rule.enforced, context)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Loads a `loads:` argument inside the user's scope. When the schema defines `object_from_id` (global
|
|
@@ -124,12 +124,23 @@ module GraphQL
|
|
|
124
124
|
end
|
|
125
125
|
private_class_method :declared_model
|
|
126
126
|
|
|
127
|
+
# Observing a type opens the only check that stands before everything inside it, whatever class its fields are. A
|
|
128
|
+
# resolver or a mutation can write, and a write cannot be undone, so a type holding one cannot be observed.
|
|
129
|
+
def self.refuse_unobservable(type, context)
|
|
130
|
+
known = context.namespace(:armory)[:observable] ||= {}.compare_by_identity
|
|
131
|
+
field = known.fetch(type) { known[type] = type.all_field_definitions.find(&:resolver) }
|
|
132
|
+
return unless field
|
|
133
|
+
|
|
134
|
+
raise ConfigurationError, "#{type.graphql_name} cannot be observed: its field #{field.graphql_name} has a resolver or a mutation, which can write. " \
|
|
135
|
+
"Remove observe:, or put it on its plain fields"
|
|
136
|
+
end
|
|
137
|
+
|
|
127
138
|
# One Authorize per query: building it (and its memo and telemetry) once per object is pure overhead.
|
|
128
139
|
def self.allowed?(subject, rule, context)
|
|
129
140
|
store = context.namespace(:armory)
|
|
130
141
|
authorize = store[:authorize] ||= begin
|
|
131
142
|
configuration = configuration(context)
|
|
132
|
-
Authorize.build(context, adapter: configuration.adapter, sinks: configuration.sinks)
|
|
143
|
+
Authorize.build(context, adapter: configuration.adapter, sinks: configuration.sinks, on_sink_error: configuration.on_sink_error)
|
|
133
144
|
end
|
|
134
145
|
|
|
135
146
|
authorize.(subject, rule, context)
|
|
@@ -5,10 +5,16 @@ require "rspec/expectations"
|
|
|
5
5
|
# `require "graphql/armory/authorization/rspec"` in your spec helper, then:
|
|
6
6
|
#
|
|
7
7
|
# expect(AppSchema).to be_fully_authorized
|
|
8
|
-
|
|
8
|
+
# expect(AppSchema).to be_fully_authorized(baseline: "config/graphql_armory_baseline.json") # fails only on new problems
|
|
9
|
+
RSpec::Matchers.define :be_fully_authorized do |*positional, baseline: nil|
|
|
10
|
+
raise ArgumentError, "be_fully_authorized takes baseline: path" unless positional.empty?
|
|
11
|
+
|
|
9
12
|
match do |schema|
|
|
10
|
-
|
|
11
|
-
@report.ok?
|
|
13
|
+
report = GraphQL::Armory::Authorization::Audit.(schema)
|
|
14
|
+
next (@report = report).ok? unless baseline
|
|
15
|
+
|
|
16
|
+
@report = GraphQL::Armory::Authorization::Audit::Baseline.load(baseline).(report)
|
|
17
|
+
@report.passes?
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
failure_message do |schema|
|
|
@@ -16,6 +22,6 @@ RSpec::Matchers.define :be_fully_authorized do
|
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
failure_message_when_negated do |schema|
|
|
19
|
-
"expected #{schema} not to be fully authorized, but the audit found no problems"
|
|
25
|
+
baseline ? "expected #{schema} not to pass its baseline, but it did:\n#{@report}" : "expected #{schema} not to be fully authorized, but the audit found no problems"
|
|
20
26
|
end
|
|
21
27
|
end
|
|
@@ -6,13 +6,13 @@ module GraphQL
|
|
|
6
6
|
# Immutable value holding what was declared, e.g. `authorize :read`.
|
|
7
7
|
# Each adapter reads the parts it needs (policy, subject, attribute, model, scope).
|
|
8
8
|
class Rule
|
|
9
|
-
attr_reader :action, :policy, :subject, :attribute, :model, :scope, :visible_when, :covers_fields, :reason
|
|
9
|
+
attr_reader :action, :policy, :subject, :attribute, :model, :scope, :visible_when, :covers_fields, :reason, :observe
|
|
10
10
|
|
|
11
11
|
def self.none(reason:)
|
|
12
12
|
new(:none, reason: reason)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def initialize(action, policy: nil, subject: nil, attribute: nil, model: nil, scope: nil, visible_when: nil, covers_fields: false, reason: nil)
|
|
15
|
+
def initialize(action, policy: nil, subject: nil, attribute: nil, model: nil, scope: nil, visible_when: nil, covers_fields: false, reason: nil, observe: nil)
|
|
16
16
|
raise ArgumentError, "a rule needs an action" if action.nil?
|
|
17
17
|
|
|
18
18
|
@action = action.to_sym
|
|
@@ -24,19 +24,35 @@ module GraphQL
|
|
|
24
24
|
@visible_when = visible_when&.to_sym
|
|
25
25
|
@covers_fields = covers_fields
|
|
26
26
|
@reason = reason
|
|
27
|
+
@observe = observe
|
|
27
28
|
|
|
28
29
|
raise ArgumentError, "a bypass needs a reason" if none? && reason.to_s.strip.empty?
|
|
29
30
|
|
|
30
31
|
raise ArgumentError, "covers_fields only applies to a bypass (`authorize :none`)" if covers_fields && !none?
|
|
32
|
+
raise ArgumentError, "observe: needs a text saying why this rule is observed, got #{observe.class}" unless observe.nil? || (observe.is_a?(String) && !observe.strip.empty?)
|
|
33
|
+
raise ArgumentError, "observe: does not apply to a bypass, which refuses nothing" if observe && none?
|
|
31
34
|
|
|
32
35
|
freeze
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def with_model(model)
|
|
39
|
+
self.class.new(action, policy: policy, subject: subject, attribute: attribute, model: model, scope: scope,
|
|
40
|
+
visible_when: visible_when, covers_fields: covers_fields, reason: reason, observe: observe)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The same rule with no observation, for what must keep refusing: lookups through a scope.
|
|
44
|
+
def enforced
|
|
45
|
+
return self unless observed?
|
|
46
|
+
|
|
36
47
|
self.class.new(action, policy: policy, subject: subject, attribute: attribute, model: model, scope: scope,
|
|
37
48
|
visible_when: visible_when, covers_fields: covers_fields, reason: reason)
|
|
38
49
|
end
|
|
39
50
|
|
|
51
|
+
# A clear "no" to this rule, on a read, is served and recorded instead of refused. See Authorize.
|
|
52
|
+
def observed?
|
|
53
|
+
!observe.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
# A bypassed type whose fields are covered by something else, such as the payload of a mutation.
|
|
41
57
|
def covers_fields?
|
|
42
58
|
covers_fields
|
|
@@ -58,7 +74,7 @@ module GraphQL
|
|
|
58
74
|
protected
|
|
59
75
|
|
|
60
76
|
def members
|
|
61
|
-
[action, policy, subject, attribute, model, scope, visible_when, covers_fields, reason]
|
|
77
|
+
[action, policy, subject, attribute, model, scope, visible_when, covers_fields, reason, observe]
|
|
62
78
|
end
|
|
63
79
|
end
|
|
64
80
|
end
|
|
@@ -13,22 +13,22 @@ module GraphQL
|
|
|
13
13
|
@telemetry = telemetry
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def self.build(adapter:, sinks: [])
|
|
17
|
-
new(adapter: adapter, telemetry: Telemetry.build(sinks: sinks))
|
|
16
|
+
def self.build(adapter:, sinks: [], on_sink_error: nil)
|
|
17
|
+
new(adapter: adapter, telemetry: Telemetry.build(sinks: sinks, on_sink_error: on_sink_error))
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def self.call(items, rule, context, adapter:, sinks: [])
|
|
21
|
-
build(adapter: adapter, sinks: sinks).(items, rule, context)
|
|
20
|
+
def self.call(items, rule, context, adapter:, sinks: [], on_sink_error: nil)
|
|
21
|
+
build(adapter: adapter, sinks: sinks, on_sink_error: on_sink_error).(items, rule, context)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def call(items, rule, context)
|
|
25
25
|
if rule.none?
|
|
26
|
-
telemetry.record(:bypassed, reason: rule.reason)
|
|
26
|
+
telemetry.record(:bypassed, context: context, reason: rule.reason)
|
|
27
27
|
return items
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
scoped = adapter.scope(adapter.actor(context), items, rule)
|
|
31
|
-
telemetry.record(:scoped, action: rule.action)
|
|
31
|
+
telemetry.record(:scoped, context: context, action: rule.action)
|
|
32
32
|
scoped
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -36,7 +36,7 @@ module GraphQL
|
|
|
36
36
|
module Authorization
|
|
37
37
|
# The schema plugin: `use GraphQL::Armory::Authorization, adapter: :pundit`.
|
|
38
38
|
# Options: `adapter:` (:pundit or an adapter object), `on_unauthorized:` (:null, :error or :raise),
|
|
39
|
-
# `sinks:` (callables that receive each telemetry signal), `own_checks:` (an app's own `authorized?`, `authorizes?`, `authorized_new`, `wrap`,
|
|
39
|
+
# `sinks:` (callables that receive each telemetry signal), `on_sink_error:` (called with the error and the signal when a sink raises), `own_checks:` (an app's own `authorized?`, `authorizes?`, `authorized_new`, `wrap`,
|
|
40
40
|
# `unauthorized_object`, `visible?`, `scope_items` or `reauthorize_scoped_objects(false)` on a class, with the reason, for the audit), `own_denial_hooks:` (the schema's own
|
|
41
41
|
# `unauthorized_field` or `unauthorized_object`, each with the reason, for the audit).
|
|
42
42
|
def self.use(schema_defn, **options)
|
data/lib/graphql/armory/ids.rb
CHANGED
|
@@ -16,8 +16,8 @@ module GraphQL
|
|
|
16
16
|
module IDs
|
|
17
17
|
SEPARATOR = "/"
|
|
18
18
|
|
|
19
|
-
def self.use(schema, encoder: nil, find: nil, sinks: [])
|
|
20
|
-
lookup = Lookup.build(encoder: encoder, find: find, sinks: sinks)
|
|
19
|
+
def self.use(schema, encoder: nil, find: nil, sinks: [], on_sink_error: nil)
|
|
20
|
+
lookup = Lookup.build(encoder: encoder, find: find, sinks: sinks, on_sink_error: on_sink_error)
|
|
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
|
|
@@ -27,8 +27,8 @@ module GraphQL
|
|
|
27
27
|
attr_reader :encoder, :find, :telemetry
|
|
28
28
|
|
|
29
29
|
# Wires the default encoder unless one is given; `use` goes through here.
|
|
30
|
-
def self.build(encoder: nil, find: nil, sinks: [])
|
|
31
|
-
new(encoder: encoder || Encoder.default, find: find, telemetry: Telemetry.build(sinks: sinks))
|
|
30
|
+
def self.build(encoder: nil, find: nil, sinks: [], on_sink_error: nil)
|
|
31
|
+
new(encoder: encoder || Encoder.default, find: find, telemetry: Telemetry.build(sinks: sinks, on_sink_error: on_sink_error))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# `new` is inert: with no encoder wired it hands out no ID and honours none. It checks what it is given.
|
|
@@ -52,25 +52,25 @@ module GraphQL
|
|
|
52
52
|
|
|
53
53
|
def object_for(id, context)
|
|
54
54
|
type_name, key = encoder.decode(id).split(SEPARATOR, 2)
|
|
55
|
-
return refuse(:malformed) if key.nil? || key.empty?
|
|
55
|
+
return refuse(:malformed, context) if key.nil? || key.empty?
|
|
56
56
|
|
|
57
57
|
type = visible_type(type_name, context)
|
|
58
|
-
return refuse(:unknown_type) unless node_type?(type)
|
|
58
|
+
return refuse(:unknown_type, context) unless node_type?(type)
|
|
59
59
|
|
|
60
60
|
object = load(type, key, context)
|
|
61
|
-
return refuse(:not_found, type: type.graphql_name) if object.nil?
|
|
61
|
+
return refuse(:not_found, context, type: type.graphql_name) if object.nil?
|
|
62
62
|
|
|
63
|
-
telemetry.record(:id_resolved, type: type.graphql_name)
|
|
63
|
+
telemetry.record(:id_resolved, context: context, type: type.graphql_name)
|
|
64
64
|
object
|
|
65
65
|
rescue Encoder::InvalidError
|
|
66
|
-
refuse(:invalid)
|
|
66
|
+
refuse(:invalid, context)
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
private
|
|
70
70
|
|
|
71
71
|
# Every refusal answers nil, like a missing record; the signal says why, never which ID.
|
|
72
|
-
def refuse(reason, **data)
|
|
73
|
-
telemetry.record(:id_refused, reason: reason, **data)
|
|
72
|
+
def refuse(reason, context, **data)
|
|
73
|
+
telemetry.record(:id_refused, context: context, reason: reason, **data)
|
|
74
74
|
nil
|
|
75
75
|
end
|
|
76
76
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module PersistedQueries
|
|
6
|
+
# Several manifests behind one `store:`: each frontend's build, and every mobile version still in use.
|
|
7
|
+
#
|
|
8
|
+
# use GraphQL::Armory::PersistedQueries, store: Manifests.build("config/persisted_queries/*.json"), strict: true
|
|
9
|
+
#
|
|
10
|
+
# A client's id is looked up across all of them, so any client may run any manifest's operation: they are one
|
|
11
|
+
# allowlist, not one per client. Any problem raises at boot.
|
|
12
|
+
class Manifests
|
|
13
|
+
GLOB = /[*?\[{]/
|
|
14
|
+
|
|
15
|
+
# `paths` is a glob pattern, a path, or a list of them. One that finds nothing raises: in strict mode its clients
|
|
16
|
+
# would all be refused. A glob skips dotfiles, and drops a missing `{a,b}` alternative: list exact paths to be sure.
|
|
17
|
+
def self.build(paths, digest: :sha256)
|
|
18
|
+
names = paths.is_a?(Array) ? paths : [paths]
|
|
19
|
+
raise ConfigurationError, "no manifest found in an empty list" if names.empty?
|
|
20
|
+
|
|
21
|
+
files = names.flat_map { |name| files_of(name) }.uniq
|
|
22
|
+
new(files.map { |file| Manifest.load(file, digest: digest) })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.files_of(name)
|
|
26
|
+
raise ConfigurationError, "manifest paths must be Strings or Pathnames, found a #{name.class}" unless name.is_a?(String) || name.respond_to?(:to_path)
|
|
27
|
+
|
|
28
|
+
path = name.is_a?(String) ? name : name.to_path
|
|
29
|
+
return [path] unless path.match?(GLOB)
|
|
30
|
+
|
|
31
|
+
found = Dir.glob(path).sort
|
|
32
|
+
found = [path] if found.empty? && File.file?(path) # a name with a `[`, which matched nothing as a pattern
|
|
33
|
+
raise ConfigurationError, "no manifest found in #{path}" if found.empty?
|
|
34
|
+
|
|
35
|
+
found
|
|
36
|
+
end
|
|
37
|
+
private_class_method :files_of
|
|
38
|
+
|
|
39
|
+
# `new` is inert: with no manifests it holds no operation, so every id is unknown.
|
|
40
|
+
def initialize(manifests = [])
|
|
41
|
+
unless manifests.is_a?(Array) && manifests.all? { |manifest| manifest.instance_of?(Manifest) }
|
|
42
|
+
raise ConfigurationError, "manifests must be an Array of Manifest; use Manifests.build for paths"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@operations = manifests.each_with_object({}) do |manifest, operations|
|
|
46
|
+
manifest.operations.each do |id, operation|
|
|
47
|
+
kept = operations[id] ||= operation
|
|
48
|
+
next if kept.body == operation.body
|
|
49
|
+
|
|
50
|
+
raise ConfigurationError, "two manifests hold different queries for #{id.match?(Manifest::ID_LIKE) ? "the id #{id}" : "one id"}"
|
|
51
|
+
end
|
|
52
|
+
end.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch(id)
|
|
56
|
+
@operations[id]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def size
|
|
60
|
+
@operations.size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def empty?
|
|
64
|
+
@operations.empty?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Armory
|
|
7
|
+
module PersistedQueries
|
|
8
|
+
# Publishes a client build's manifest to Redis, for `RedisStore` to read. It only adds: an id that already holds
|
|
9
|
+
# another query raises and stays as it was. Publishing again is safe, so a failed run can simply run again.
|
|
10
|
+
#
|
|
11
|
+
# GraphQL::Armory::PersistedQueries::Publisher.call(Manifest.load("manifest.json"), redis: Redis.new(url: url))
|
|
12
|
+
class Publisher
|
|
13
|
+
# An id it could not store, or that holds another query. The message quotes neither query.
|
|
14
|
+
class PublishError < Armory::Error; end
|
|
15
|
+
|
|
16
|
+
Result = Struct.new(:published, :unchanged, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
def self.build(redis:, prefix: RedisStore::DEFAULT_PREFIX)
|
|
19
|
+
raise ConfigurationError, "redis: must answer get and set, like Redis.new from the redis gem" unless redis.respond_to?(:get) && redis.respond_to?(:set)
|
|
20
|
+
raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
|
|
21
|
+
|
|
22
|
+
new(redis: redis, prefix: prefix)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.call(manifest, **options)
|
|
26
|
+
build(**options).(manifest)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `new` is inert: with no Redis wired, nothing is stored and `call` raises.
|
|
30
|
+
def initialize(redis: RedisStore::Client::Null.new, prefix: RedisStore::DEFAULT_PREFIX)
|
|
31
|
+
@redis = redis
|
|
32
|
+
@prefix = prefix
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(manifest)
|
|
36
|
+
raise ConfigurationError, "publish a Manifest: Manifest.load checks every id and query" unless manifest.instance_of?(Manifest)
|
|
37
|
+
|
|
38
|
+
check_ids(manifest)
|
|
39
|
+
published = manifest.operations.count { |id, operation| publish(id, operation.body) }
|
|
40
|
+
Result.new(published: published, unchanged: manifest.size - published)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Before anything is stored: `RedisStore` never asks for an id of another shape.
|
|
46
|
+
def check_ids(manifest)
|
|
47
|
+
manifest.operations.keys.each_with_index do |id, index|
|
|
48
|
+
raise ConfigurationError, "the operation at entry #{index} has an id RedisStore cannot read" unless id.match?(Manifest::ID_LIKE)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Answers whether it was new. SET NX, so two runs at once cannot overwrite each other.
|
|
53
|
+
def publish(id, body)
|
|
54
|
+
key = "#{@prefix}#{id}"
|
|
55
|
+
return true if @redis.set(key, body, nx: true)
|
|
56
|
+
|
|
57
|
+
stored = @redis.get(key)
|
|
58
|
+
raise PublishError, "operation #{id} was not stored" if stored.nil?
|
|
59
|
+
raise PublishError, "operation #{id} already holds another query: published operations never change" unless stored.b == body.b
|
|
60
|
+
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The `graphql-armory-publish` executable, for a client's CI. Answers the exit code. Redis's messages may hold
|
|
65
|
+
# the URL and its password, so only the error's class is printed.
|
|
66
|
+
class Command
|
|
67
|
+
USAGE = "Usage: graphql-armory-publish [--digest sha256|sha1|md5|false] [--prefix PREFIX] MANIFEST...\nReads REDIS_URL."
|
|
68
|
+
|
|
69
|
+
def self.call(argv = ARGV, env: ENV, out: $stdout, err: $stderr, client: nil)
|
|
70
|
+
new(env: env, out: out, err: err, client: client).(argv)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(env: {}, out: $stdout, err: $stderr, client: nil)
|
|
74
|
+
@env = env
|
|
75
|
+
@out = out
|
|
76
|
+
@err = err
|
|
77
|
+
@client = client
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def call(argv)
|
|
81
|
+
digest, prefix, paths = options(argv)
|
|
82
|
+
return fail_with(USAGE) if paths.empty?
|
|
83
|
+
|
|
84
|
+
url = @env["REDIS_URL"]
|
|
85
|
+
return fail_with("REDIS_URL is not set") if url.to_s.empty?
|
|
86
|
+
|
|
87
|
+
manifests = paths.map { |path| [path, Manifest.load(path, digest: digest)] }
|
|
88
|
+
publisher = Publisher.build(redis: @client || redis(url), prefix: prefix)
|
|
89
|
+
manifests.each do |path, manifest|
|
|
90
|
+
result = publisher.(manifest)
|
|
91
|
+
@out.puts "#{path}: #{result.published} published, #{result.unchanged} unchanged"
|
|
92
|
+
end
|
|
93
|
+
0
|
|
94
|
+
rescue OptionParser::ParseError, Armory::Error => error
|
|
95
|
+
fail_with(error.message)
|
|
96
|
+
rescue LoadError
|
|
97
|
+
fail_with("graphql-armory-publish needs the redis gem: gem install redis")
|
|
98
|
+
rescue StandardError => error
|
|
99
|
+
fail_with("publishing failed (#{error.class})")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def options(argv)
|
|
105
|
+
digest = :sha256
|
|
106
|
+
prefix = RedisStore::DEFAULT_PREFIX
|
|
107
|
+
paths = OptionParser.new do |parser|
|
|
108
|
+
parser.on("--digest NAME") { |name| digest = name == "false" ? false : name.to_sym }
|
|
109
|
+
parser.on("--prefix PREFIX") { |given| prefix = given }
|
|
110
|
+
end.parse(argv)
|
|
111
|
+
[digest, prefix, paths]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def redis(url)
|
|
115
|
+
require "redis"
|
|
116
|
+
Redis.new(url: url)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fail_with(message)
|
|
120
|
+
@err.puts message
|
|
121
|
+
1
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Armory
|
|
5
|
+
module PersistedQueries
|
|
6
|
+
# Operations a client's CI published with `Publisher`, read while the server runs, so a new frontend needs no
|
|
7
|
+
# server deploy.
|
|
8
|
+
#
|
|
9
|
+
# store = GraphQL::Armory::PersistedQueries::RedisStore.build(redis: Redis.new(url: ENV.fetch("REDIS_URL")))
|
|
10
|
+
#
|
|
11
|
+
# An operation it read stays in memory: published operations never change, so what it already knows keeps working
|
|
12
|
+
# when Redis fails. An id it cannot find, or cannot check, is unknown: nothing runs. This gem does not depend on
|
|
13
|
+
# the redis gem: the app adds it.
|
|
14
|
+
class RedisStore
|
|
15
|
+
DEFAULT_PREFIX = "graphql-armory:persisted-queries:"
|
|
16
|
+
DEFAULT_ON_ERROR = ->(error) { warn "graphql-armory: persisted query store failed (#{error.class})" }
|
|
17
|
+
|
|
18
|
+
# A stored value that does not parse, or is not the digest of its id. The message quotes neither.
|
|
19
|
+
class CorruptOperation < Armory::Error; end
|
|
20
|
+
|
|
21
|
+
# What the store and the publisher need from Redis.
|
|
22
|
+
module Client
|
|
23
|
+
# The Redis of a store nobody wired: it holds nothing and stores nothing.
|
|
24
|
+
class Null
|
|
25
|
+
def get(_key)
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set(_key, _value, nx: false)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Holds its keys in memory like Redis, records its calls and fails when told: for tests.
|
|
35
|
+
class Substitute
|
|
36
|
+
attr_reader :calls
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@data = {}
|
|
40
|
+
@calls = []
|
|
41
|
+
@failure = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Raises `error` from every call until `recover`.
|
|
45
|
+
def fail_with(error)
|
|
46
|
+
@failure = error
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def recover
|
|
50
|
+
@failure = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get(key)
|
|
54
|
+
@calls << [:get, key]
|
|
55
|
+
raise @failure if @failure
|
|
56
|
+
|
|
57
|
+
@data[key]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def set(key, value, nx: false)
|
|
61
|
+
@calls << [:set, key]
|
|
62
|
+
raise @failure if @failure
|
|
63
|
+
return false if nx && @data.key?(key)
|
|
64
|
+
|
|
65
|
+
@data[key] = value
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Validates the settings and wires the real Redis client.
|
|
72
|
+
def self.build(redis:, prefix: DEFAULT_PREFIX, digest: :sha256, on_error: DEFAULT_ON_ERROR)
|
|
73
|
+
raise ConfigurationError, "redis: must answer get and set, like Redis.new from the redis gem" unless redis.respond_to?(:get) && redis.respond_to?(:set)
|
|
74
|
+
raise ConfigurationError, "prefix: must be a non-empty String, got #{prefix.inspect}" unless prefix.is_a?(String) && !prefix.empty?
|
|
75
|
+
unless digest == false || Manifest::DIGESTS.key?(digest)
|
|
76
|
+
raise ConfigurationError, "unknown digest #{digest.inspect}: use #{Manifest::DIGESTS.keys.map(&:inspect).join(", ")} or false"
|
|
77
|
+
end
|
|
78
|
+
raise ConfigurationError, "on_error: must be a callable that takes the error" unless on_error.respond_to?(:call)
|
|
79
|
+
|
|
80
|
+
new(redis: redis, prefix: prefix, digest: digest, on_error: on_error)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `new` is inert: with no Redis wired it holds no operation, so every id is unknown.
|
|
84
|
+
def initialize(redis: Client::Null.new, prefix: DEFAULT_PREFIX, digest: :sha256, on_error: DEFAULT_ON_ERROR)
|
|
85
|
+
@redis = redis
|
|
86
|
+
@prefix = prefix
|
|
87
|
+
@digest = digest == false ? nil : Manifest::DIGESTS.fetch(digest)
|
|
88
|
+
@on_error = on_error
|
|
89
|
+
@operations = {}
|
|
90
|
+
@mutex = Mutex.new
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fetch(id)
|
|
94
|
+
# Bytes: a regexp raises on a client's broken encoding.
|
|
95
|
+
return unless id.is_a?(String) && id.b.match?(Manifest::ID_LIKE)
|
|
96
|
+
|
|
97
|
+
@operations[id] || read(id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Only an unwired store is known to be empty: `strict: false` refuses it at boot, as it does the Null store.
|
|
101
|
+
def empty?
|
|
102
|
+
@redis.is_a?(Client::Null)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Only what it found is remembered: an unknown id may be published a moment later.
|
|
108
|
+
def read(id)
|
|
109
|
+
body = @redis.get("#{@prefix}#{id}")
|
|
110
|
+
return if body.nil?
|
|
111
|
+
|
|
112
|
+
operation = operation(id, body)
|
|
113
|
+
@mutex.synchronize { @operations[id] ||= operation }
|
|
114
|
+
rescue StandardError => error
|
|
115
|
+
@on_error.call(error)
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def operation(id, stored)
|
|
120
|
+
raise CorruptOperation, "operation #{id} is not text" unless stored.is_a?(String)
|
|
121
|
+
|
|
122
|
+
# redis-rb may answer in another encoding; a query compares as text with what the client sends.
|
|
123
|
+
body = stored.dup.force_encoding(Encoding::UTF_8).freeze
|
|
124
|
+
raise CorruptOperation, "operation #{id} is not the digest of its query" if @digest && @digest.hexdigest(body) != id
|
|
125
|
+
|
|
126
|
+
Manifest::Operation.new(id: id.dup.freeze, body: body, document: parse(id, body)).freeze
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse(id, body)
|
|
130
|
+
GraphQL.parse(body)
|
|
131
|
+
rescue GraphQL::ParseError
|
|
132
|
+
raise CorruptOperation, "operation #{id} does not parse"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|