elasticgraph-query_registry 0.18.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +139 -0
- data/elasticgraph-query_registry.gemspec +22 -0
- data/lib/elastic_graph/query_registry/client_data.rb +103 -0
- data/lib/elastic_graph/query_registry/graphql_extension.rb +104 -0
- data/lib/elastic_graph/query_registry/query_validator.rb +98 -0
- data/lib/elastic_graph/query_registry/query_validators/for_registered_client.rb +124 -0
- data/lib/elastic_graph/query_registry/query_validators/for_unregistered_client.rb +31 -0
- data/lib/elastic_graph/query_registry/rake_tasks.rb +195 -0
- data/lib/elastic_graph/query_registry/registry.rb +101 -0
- data/lib/elastic_graph/query_registry/variable_backward_incompatibility_detector.rb +104 -0
- data/lib/elastic_graph/query_registry/variable_dumper.rb +110 -0
- metadata +314 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f8e00afd5a4658acd8711b2a9ac9056e7edd348d17285c12e26be6b75d2123b1
|
4
|
+
data.tar.gz: ed78fad975a7b3c924623a481525c63ecae576c254319174e20c2e8d32344e84
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d140a563082e3fe1f7612a8f9a859a48a7d35027b8a06c4fdd2d4b5358cf3b1e27def9e4a518389a88ee68a9e844deda7c6fd9279f8ae0ea007233f2394fb358
|
7
|
+
data.tar.gz: 597d37c7f2e9eff342393a5d7888e57eb5f450a8206ab55f563f1cd72fdedbd5026ef13392f206481e35bbc39f77157a35dce7638e74a4a50daba7db7a343e07
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Block, Inc.
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
# ElasticGraph::QueryRegistry
|
2
|
+
|
3
|
+
`ElasticGraph::QueryRegistry` provides a simple source-controlled query
|
4
|
+
registry for ElasticGraph applications. This is designed for cases where
|
5
|
+
the clients of your application are other internal teams in your organization,
|
6
|
+
who are willing to register their queries before using them.
|
7
|
+
|
8
|
+
Query registration provides a few key benefits:
|
9
|
+
|
10
|
+
* It gives you as the application owner the chance to vet queries and
|
11
|
+
give feedback to your clients. Queries may not initially be written
|
12
|
+
in the most optimal way (e.g. leveraging your sharding strategy), so
|
13
|
+
the review process gives you a chance to provide feedback.
|
14
|
+
* It allows you to provide stronger guarantees around schema changes.
|
15
|
+
Tooling is included that will validate each and every registered query
|
16
|
+
against your schema as part of your CI build, allowing you to quickly
|
17
|
+
iterate on your schema without needing to check if you'll break clients.
|
18
|
+
* It allows you to control the data clients have access to. When
|
19
|
+
a client attempts to register a query accessing fields they aren't
|
20
|
+
allowed to, you can choose not to approve the query. Once setup and
|
21
|
+
configured, this library will block clients from submitting queries
|
22
|
+
that have not been registered.
|
23
|
+
* Your GraphQL endpoint will be a bit more efficient. Parsing large
|
24
|
+
GraphQL queries can be a bit slow (in our testing, a 10 KB query
|
25
|
+
string takes about ~10ms to parse), and the registry will cache and
|
26
|
+
reuse the parsed form of registered queries.
|
27
|
+
|
28
|
+
Importantly, once installed, registered clients who send unregistered
|
29
|
+
queries will get errors. Unregistered clients can similarly be blocked
|
30
|
+
if desired based on a configuration setting.
|
31
|
+
|
32
|
+
## Query Verification Guarantees
|
33
|
+
|
34
|
+
The query verification provided by this library is limited in scope. It
|
35
|
+
only checks to see if the queries and schema are compatible (in the sense
|
36
|
+
that the ElasticGraph endpoint will be able to successfully respond to
|
37
|
+
the queries). It does _not_ give any guarantee that a schema change is
|
38
|
+
100% safe for clients. For example, if you change a non-null field to be
|
39
|
+
nullable, it has no impact on ElasticGraph's ability to respond to a query
|
40
|
+
(and the verification performed by this library will allow it), but it may
|
41
|
+
break the client (e.g. if the client's usage of the response assumes
|
42
|
+
non-null field values).
|
43
|
+
|
44
|
+
When changing the GraphQL schema of an ElasticGraph application, you
|
45
|
+
will still need to consider how it may impact clients, but you won't
|
46
|
+
need to worry about ElasticGraph beginning to return errors to any
|
47
|
+
existing queries.
|
48
|
+
|
49
|
+
## Directory Structure
|
50
|
+
|
51
|
+
This library uses a directory as the registry. Conventionally, this
|
52
|
+
would go in `config/queries` but it can really go anywhere. The directory
|
53
|
+
structure will look like this:
|
54
|
+
|
55
|
+
```
|
56
|
+
config
|
57
|
+
└── queries
|
58
|
+
├── client1
|
59
|
+
│ ├── query1.graphql
|
60
|
+
│ └── query2.graphql
|
61
|
+
├── client2
|
62
|
+
└── client3
|
63
|
+
└── query1.graphql
|
64
|
+
```
|
65
|
+
|
66
|
+
Within the registry directory, there is a subdirectory for each
|
67
|
+
registered client. Each client directory contains that client's
|
68
|
+
registered queries as a set of `*.graphql` files (the extension is
|
69
|
+
required). Note that a client can be registered with no
|
70
|
+
associated queries (such as `client2`, above). This can be important
|
71
|
+
when you have configured `allow_unregistered_clients: true`. With
|
72
|
+
this setup, `client2` will not be able to submit any queries, but
|
73
|
+
a completely unregistered client (say, `client4`) will be able to
|
74
|
+
execute any query.
|
75
|
+
|
76
|
+
## Setup
|
77
|
+
|
78
|
+
First, add `elasticgraph-query_registry` to your `Gemfile`:
|
79
|
+
|
80
|
+
``` ruby
|
81
|
+
gem "elasticgraph-query_registry"
|
82
|
+
```
|
83
|
+
|
84
|
+
Next, configure this library in your ElasticGraph config YAML files:
|
85
|
+
|
86
|
+
``` yaml
|
87
|
+
graphql:
|
88
|
+
extension_modules:
|
89
|
+
- require_path: elastic_graph/query_registry/graphql_extension
|
90
|
+
extension_name: ElasticGraph::QueryRegistry::GraphQLExtension
|
91
|
+
query_registry:
|
92
|
+
allow_unregistered_clients: false
|
93
|
+
allow_any_query_for_clients:
|
94
|
+
- adhoc_client
|
95
|
+
path_to_registry: config/queries
|
96
|
+
```
|
97
|
+
|
98
|
+
Next, load the `ElasticGraph::QueryRegistry` rake tasks in your `Rakefile`:
|
99
|
+
|
100
|
+
``` ruby
|
101
|
+
require "elastic_graph/query_registry/rake_tasks"
|
102
|
+
|
103
|
+
ElasticGraph::QueryRegistry::RakeTasks.from_yaml_file(
|
104
|
+
"path/to/settings.yaml",
|
105
|
+
"config/queries",
|
106
|
+
require_eg_latency_slo_directive: true
|
107
|
+
)
|
108
|
+
```
|
109
|
+
|
110
|
+
You'll want to add `rake query_registry:validate_queries` to your CI build so
|
111
|
+
that every registered query is validated as part of every build.
|
112
|
+
|
113
|
+
Finally, your application needs to include a `client:` when submitting
|
114
|
+
each GraphQL query for execution. The client `name` should match the
|
115
|
+
name of one of the registry client subdirectories. If you are using
|
116
|
+
`elasticgraph-lambda`, note that it does this automatically, but you may
|
117
|
+
need to configure `aws_arn_client_name_extraction_regex` so that it is
|
118
|
+
able to extract the `client_name` from the IAM ARN correctly.
|
119
|
+
|
120
|
+
Important note: if your application fails to identify clients properly,
|
121
|
+
and `allow_unregistered_clients` is set to `true`, then _all_ clients
|
122
|
+
will be allowed to execute _all_ queries! We recommend you set
|
123
|
+
`allow_unregistered_clients` to `false` unless you specifically need
|
124
|
+
to allow unregistered clients. For specific clients that need to be
|
125
|
+
allowed to run any query, you can list them in `allow_any_query_for_clients`.
|
126
|
+
|
127
|
+
## Workflow
|
128
|
+
|
129
|
+
This library also uses some generated artifacts (`*.variables.yaml` files)
|
130
|
+
so it can detect when a change to the structure or type of a variable is
|
131
|
+
backward-incompatible. For this to work, it requires that the generated
|
132
|
+
variables files are kept up-to-date. Any time a change impacts the structure
|
133
|
+
of any variables used by any queries, you'll need to run a task like
|
134
|
+
`query_registry:dump_variables[client_name, query_name]` (or
|
135
|
+
`query_registry:dump_variables:all`) to update the artifacts.
|
136
|
+
|
137
|
+
Don't worry about if you forget this, though--the
|
138
|
+
`query_registry:validate_queries` task will also fail and give you
|
139
|
+
instructions anytime a variables file is not up-to-date.
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require_relative "../gemspec_helper"
|
10
|
+
|
11
|
+
ElasticGraphGemspecHelper.define_elasticgraph_gem(gemspec_file: __FILE__, category: :extension) do |spec, eg_version|
|
12
|
+
spec.summary = "An ElasticGraph extension that supports safer schema evolution by limiting GraphQL queries based on " \
|
13
|
+
"a registry and validating registered queries against the schema."
|
14
|
+
|
15
|
+
spec.add_dependency "elasticgraph-graphql", eg_version
|
16
|
+
spec.add_dependency "elasticgraph-support", eg_version
|
17
|
+
spec.add_dependency "graphql", ">= 2.3.7", "< 2.4"
|
18
|
+
spec.add_dependency "rake", "~> 13.2"
|
19
|
+
|
20
|
+
spec.add_development_dependency "elasticgraph-elasticsearch", eg_version
|
21
|
+
spec.add_development_dependency "elasticgraph-opensearch", eg_version
|
22
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
module ElasticGraph
|
10
|
+
module QueryRegistry
|
11
|
+
ClientData = ::Data.define(:queries_by_original_string, :queries_by_last_string, :canonical_query_strings, :operation_names, :schema_element_names) do
|
12
|
+
# @implements ClientData
|
13
|
+
def self.from(schema, registered_query_strings)
|
14
|
+
queries_by_original_string = registered_query_strings.to_h do |query_string|
|
15
|
+
[query_string, ::GraphQL::Query.new(schema.graphql_schema, query_string, validate: false)]
|
16
|
+
end
|
17
|
+
|
18
|
+
canonical_query_strings = queries_by_original_string.values.map do |q|
|
19
|
+
canonical_query_string_from(q, schema_element_names: schema.element_names)
|
20
|
+
end.to_set
|
21
|
+
|
22
|
+
operation_names = queries_by_original_string.values.flat_map { |q| q.operations.keys }.to_set
|
23
|
+
|
24
|
+
new(
|
25
|
+
queries_by_original_string: queries_by_original_string,
|
26
|
+
queries_by_last_string: {},
|
27
|
+
canonical_query_strings: canonical_query_strings,
|
28
|
+
operation_names: operation_names,
|
29
|
+
schema_element_names: schema.element_names
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
def cached_query_for(query_string)
|
34
|
+
queries_by_original_string[query_string] || queries_by_last_string[query_string]
|
35
|
+
end
|
36
|
+
|
37
|
+
def with_updated_last_query(query_string, query)
|
38
|
+
canonical_string = canonical_query_string_from(query)
|
39
|
+
|
40
|
+
# We normally expect to only see one alternate query form from a client. However, a misbehaving
|
41
|
+
# client could send us a slightly different query string on each request (imagine if the query
|
42
|
+
# had a dynamically generated comment with a timestamp). Here we guard against that case by
|
43
|
+
# pruning out the previous hash entry that resolves to the same registered query, ensuring
|
44
|
+
# we only cache the most recently seen query string. Note that this operation is unfortunately
|
45
|
+
# O(N) instead of O(1) but we expect this operation to happen rarely (and we don't expect many
|
46
|
+
# entries in the `queries_by_last_string` hash). We could maintain a 2nd parallel data structure
|
47
|
+
# allowing an `O(1)` lookup here but I'd rather not introduce that added complexity for marginal
|
48
|
+
# benefit.
|
49
|
+
updated_queries_by_last_string = queries_by_last_string.reject do |_, cached_query|
|
50
|
+
canonical_query_string_from(cached_query) == canonical_string
|
51
|
+
end.merge(query_string => query)
|
52
|
+
|
53
|
+
with(queries_by_last_string: updated_queries_by_last_string)
|
54
|
+
end
|
55
|
+
|
56
|
+
def unregistered_query_error_for(query, client)
|
57
|
+
if operation_names.include?(query.operation_name.to_s)
|
58
|
+
"Query #{fingerprint_for(query)} differs from the registered form of `#{query.operation_name}` " \
|
59
|
+
"for client #{client.description}."
|
60
|
+
else
|
61
|
+
"Query #{fingerprint_for(query)} is unregistered; client #{client.description} has no " \
|
62
|
+
"registered query with a `#{query.operation_name}` operation."
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def fingerprint_for(query)
|
69
|
+
# `query.fingerprint` raises an error if the query string is nil:
|
70
|
+
# https://github.com/rmosolgo/graphql-ruby/issues/4942
|
71
|
+
query.query_string ? query.fingerprint : "(no query string)"
|
72
|
+
end
|
73
|
+
|
74
|
+
def canonical_query_string_from(query)
|
75
|
+
ClientData.canonical_query_string_from(query, schema_element_names: schema_element_names)
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.canonical_query_string_from(query, schema_element_names:)
|
79
|
+
return "" unless (document = query.document)
|
80
|
+
|
81
|
+
canonicalized_definitions = document.definitions.map do |definition|
|
82
|
+
if definition.directives.empty?
|
83
|
+
definition
|
84
|
+
else
|
85
|
+
# Ignore the `@egLatencySlo` directive if it is present. We want to allow it to be included (or not)
|
86
|
+
# and potentially have different values from the registered query so that clients don't have to register
|
87
|
+
# a new version of their query just to change the latency SLO value.
|
88
|
+
#
|
89
|
+
# Note: we don't ignore _all_ directives here because other directives might cause significant behavioral
|
90
|
+
# changes that should be enforced by the registry query approval process.
|
91
|
+
directives = definition.directives.reject do |dir|
|
92
|
+
dir.name == schema_element_names.eg_latency_slo
|
93
|
+
end
|
94
|
+
|
95
|
+
definition.merge(directives: directives)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
document.merge(definitions: canonicalized_definitions).to_query_string
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/graphql/query_executor"
|
10
|
+
require "elastic_graph/query_registry/registry"
|
11
|
+
require "graphql/query/result"
|
12
|
+
require "pathname"
|
13
|
+
|
14
|
+
module ElasticGraph
|
15
|
+
module QueryRegistry
|
16
|
+
module GraphQLExtension
|
17
|
+
def graphql_query_executor
|
18
|
+
@graphql_query_executor ||= begin
|
19
|
+
registry_config = QueryRegistry::Config.from_parsed_yaml(config.extension_settings)
|
20
|
+
|
21
|
+
RegistryAwareQueryExecutor.new(
|
22
|
+
schema: schema,
|
23
|
+
monotonic_clock: monotonic_clock,
|
24
|
+
logger: logger,
|
25
|
+
slow_query_threshold_ms: config.slow_query_latency_warning_threshold_in_ms,
|
26
|
+
datastore_search_router: datastore_search_router,
|
27
|
+
registry_directory: registry_config.path_to_registry,
|
28
|
+
allow_unregistered_clients: registry_config.allow_unregistered_clients,
|
29
|
+
allow_any_query_for_clients: registry_config.allow_any_query_for_clients
|
30
|
+
)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class RegistryAwareQueryExecutor < GraphQL::QueryExecutor
|
36
|
+
def initialize(
|
37
|
+
registry_directory:,
|
38
|
+
allow_unregistered_clients:,
|
39
|
+
allow_any_query_for_clients:,
|
40
|
+
schema:,
|
41
|
+
monotonic_clock:,
|
42
|
+
logger:,
|
43
|
+
slow_query_threshold_ms:,
|
44
|
+
datastore_search_router:
|
45
|
+
)
|
46
|
+
super(
|
47
|
+
schema: schema,
|
48
|
+
monotonic_clock: monotonic_clock,
|
49
|
+
logger: logger,
|
50
|
+
slow_query_threshold_ms: slow_query_threshold_ms,
|
51
|
+
datastore_search_router: datastore_search_router
|
52
|
+
)
|
53
|
+
|
54
|
+
@registry = Registry.build_from_directory(
|
55
|
+
schema,
|
56
|
+
registry_directory,
|
57
|
+
allow_unregistered_clients: allow_unregistered_clients,
|
58
|
+
allow_any_query_for_clients: allow_any_query_for_clients
|
59
|
+
)
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def build_and_execute_query(query_string:, variables:, operation_name:, context:, client:)
|
65
|
+
query, errors = @registry.build_and_validate_query(
|
66
|
+
query_string,
|
67
|
+
variables: variables,
|
68
|
+
operation_name: operation_name,
|
69
|
+
context: context,
|
70
|
+
client: client
|
71
|
+
)
|
72
|
+
|
73
|
+
if errors.empty?
|
74
|
+
[query, execute_query(query, client: client)]
|
75
|
+
else
|
76
|
+
result = ::GraphQL::Query::Result.new(
|
77
|
+
query: nil,
|
78
|
+
values: {"errors" => errors.map { |e| {"message" => e} }}
|
79
|
+
)
|
80
|
+
|
81
|
+
[query, result]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
class Config < ::Data.define(:path_to_registry, :allow_unregistered_clients, :allow_any_query_for_clients)
|
87
|
+
def self.from_parsed_yaml(hash)
|
88
|
+
hash = hash.fetch("query_registry") { return DEFAULT }
|
89
|
+
|
90
|
+
new(
|
91
|
+
path_to_registry: hash.fetch("path_to_registry"),
|
92
|
+
allow_unregistered_clients: hash.fetch("allow_unregistered_clients"),
|
93
|
+
allow_any_query_for_clients: hash.fetch("allow_any_query_for_clients")
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
DEFAULT = new(
|
98
|
+
path_to_registry: (_ = __dir__),
|
99
|
+
allow_unregistered_clients: true,
|
100
|
+
allow_any_query_for_clients: []
|
101
|
+
)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/query_registry/variable_backward_incompatibility_detector"
|
10
|
+
require "elastic_graph/query_registry/variable_dumper"
|
11
|
+
require "graphql"
|
12
|
+
|
13
|
+
module ElasticGraph
|
14
|
+
module QueryRegistry
|
15
|
+
class QueryValidator
|
16
|
+
def initialize(schema, require_eg_latency_slo_directive:)
|
17
|
+
@graphql_schema = schema.graphql_schema
|
18
|
+
@schema_element_names = schema.element_names
|
19
|
+
@var_dumper = VariableDumper.new(@graphql_schema)
|
20
|
+
@var_incompat_detector = VariableBackwardIncompatibilityDetector.new
|
21
|
+
@require_eg_latency_slo_directive = require_eg_latency_slo_directive
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate(query_string, previously_dumped_variables:, client_name:, query_name:)
|
25
|
+
# We pass `validate: false` since we do query validation on the operation level down below.
|
26
|
+
query = ::GraphQL::Query.new(@graphql_schema, query_string, validate: false)
|
27
|
+
|
28
|
+
if query.document.nil?
|
29
|
+
{nil => query.static_errors.map(&:to_h)}
|
30
|
+
else
|
31
|
+
# @type var fragments: ::Array[::GraphQL::Language::Nodes::FragmentDefinition]
|
32
|
+
# @type var operations: ::Array[::GraphQL::Language::Nodes::OperationDefinition]
|
33
|
+
fragments, operations = _ = query.document.definitions.partition do |definition|
|
34
|
+
definition.is_a?(::GraphQL::Language::Nodes::FragmentDefinition)
|
35
|
+
end
|
36
|
+
|
37
|
+
newly_dumped_variables = @var_dumper.dump_variables_for_operations(operations)
|
38
|
+
|
39
|
+
operations.to_h do |operation|
|
40
|
+
errors = if operation.name.nil?
|
41
|
+
[{"message" => "The query has no named operations. We require all registered queries to be named for more useful logging."}]
|
42
|
+
else
|
43
|
+
variables_errors = variables_errors_for(_ = operation.name, previously_dumped_variables, newly_dumped_variables, client_name, query_name)
|
44
|
+
directive_errors = directive_errors_for(operation)
|
45
|
+
|
46
|
+
static_validation_errors_for(query, operation, fragments) + variables_errors + directive_errors
|
47
|
+
end
|
48
|
+
|
49
|
+
[operation.name, errors]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def variables_errors_for(operation_name, old_dumped_variables, new_dumped_variables, client_name, query_name)
|
57
|
+
rake_task = "rake \"query_registry:dump_variables[#{client_name}, #{query_name}]\""
|
58
|
+
|
59
|
+
if old_dumped_variables.nil? || old_dumped_variables[operation_name].nil?
|
60
|
+
return [{"message" => "No dumped variables for this operation exist. Correct by running: `#{rake_task}`"}]
|
61
|
+
end
|
62
|
+
|
63
|
+
old_op_vars = old_dumped_variables[operation_name]
|
64
|
+
new_op_vars = new_dumped_variables[operation_name]
|
65
|
+
|
66
|
+
if old_op_vars == new_op_vars
|
67
|
+
# The previously dumped variables are up-to-date. No errors in this case.
|
68
|
+
[]
|
69
|
+
elsif (incompatibilities = @var_incompat_detector.detect(old_op_vars: old_op_vars, new_op_vars: new_op_vars)).any?
|
70
|
+
# The structure of variables has changed in a way that may break the client. Tell the user to verify with them.
|
71
|
+
descriptions = incompatibilities.map(&:description).join(", ")
|
72
|
+
[{
|
73
|
+
"message" => "The structure of the query variables have had backwards-incompatible changes that may break `#{client_name}`: #{descriptions}. " \
|
74
|
+
"To proceed, check with the client to see if this change is compatible with their logic, then run `#{rake_task}` to update the dumped info."
|
75
|
+
}]
|
76
|
+
else
|
77
|
+
# The change to the variables shouldn't break the client, but we still need to keep the file up-to-date.
|
78
|
+
[{"message" => "The variables file is out-of-date, but the changes to them should not impact `#{client_name}`. Run `#{rake_task}` to update the file."}]
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def directive_errors_for(operation)
|
83
|
+
if @require_eg_latency_slo_directive && operation.directives.none? { |dir| dir.name == @schema_element_names.eg_latency_slo }
|
84
|
+
[{"message" => "Your `#{operation.name}` operation is missing the required `@#{@schema_element_names.eg_latency_slo}(#{@schema_element_names.ms}: Int!)` directive."}]
|
85
|
+
else
|
86
|
+
[]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def static_validation_errors_for(query, operation, fragments)
|
91
|
+
# Build a document with just this operation so that we can validate it in isolation, apart from the other operations.
|
92
|
+
document = query.document.merge(definitions: [operation] + fragments)
|
93
|
+
query = ::GraphQL::Query.new(@graphql_schema, nil, document: document, validate: false)
|
94
|
+
@graphql_schema.static_validator.validate(query).fetch(:errors).map(&:to_h)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/graphql/client"
|
10
|
+
require "elastic_graph/query_registry/client_data"
|
11
|
+
require "graphql"
|
12
|
+
|
13
|
+
module ElasticGraph
|
14
|
+
module QueryRegistry
|
15
|
+
module QueryValidators
|
16
|
+
# Query validator implementation used for registered clients.
|
17
|
+
class ForRegisteredClient < ::Data.define(
|
18
|
+
:schema,
|
19
|
+
:graphql_schema,
|
20
|
+
:allow_any_query_for_clients,
|
21
|
+
:client_data_by_client_name,
|
22
|
+
:client_cache_mutex,
|
23
|
+
:provide_query_strings_for_client
|
24
|
+
)
|
25
|
+
def initialize(schema:, client_names:, allow_any_query_for_clients:, provide_query_strings_for_client:)
|
26
|
+
super(
|
27
|
+
schema: schema,
|
28
|
+
graphql_schema: schema.graphql_schema,
|
29
|
+
allow_any_query_for_clients: allow_any_query_for_clients,
|
30
|
+
client_cache_mutex: ::Mutex.new,
|
31
|
+
provide_query_strings_for_client: provide_query_strings_for_client,
|
32
|
+
client_data_by_client_name: client_names.to_h { |name| [name, nil] }.merge(
|
33
|
+
# Register a built-in GraphQL query that ElasticGraph itself sometimes has to make.
|
34
|
+
GraphQL::Client::ELASTICGRAPH_INTERNAL.name => ClientData.from(schema, [GraphQL::EAGER_LOAD_QUERY])
|
35
|
+
)
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def applies_to?(client)
|
40
|
+
return false unless (client_name = client&.name)
|
41
|
+
client_data_by_client_name.key?(client_name)
|
42
|
+
end
|
43
|
+
|
44
|
+
def build_and_validate_query(query_string, client:, variables: {}, operation_name: nil, context: {})
|
45
|
+
client_data = client_data_for(client.name)
|
46
|
+
|
47
|
+
if (cached_query = client_data.cached_query_for(query_string.to_s))
|
48
|
+
prepared_query = prepare_query_for_execution(cached_query, variables: variables, operation_name: operation_name, context: context)
|
49
|
+
return [prepared_query, []]
|
50
|
+
end
|
51
|
+
|
52
|
+
query = yield
|
53
|
+
|
54
|
+
# This client allows any query, so we can just return the query with no errors here.
|
55
|
+
# Note: we could put this at the top of the method, but if the query is registered and matches
|
56
|
+
# the registered form, the `cached_query` above is more efficient as it avoids unnecessarily
|
57
|
+
# parsing the query.
|
58
|
+
return [query, []] if allow_any_query_for_clients.include?(client.name)
|
59
|
+
|
60
|
+
if !client_data.canonical_query_strings.include?(ClientData.canonical_query_string_from(query, schema_element_names: schema.element_names))
|
61
|
+
return [query, [client_data.unregistered_query_error_for(query, client)]]
|
62
|
+
end
|
63
|
+
|
64
|
+
# The query is slightly different from a registered query, but not in any material fashion
|
65
|
+
# (such as a whitespace or comment difference). Since query parsing can be kinda slow on
|
66
|
+
# large queries (in our benchmarking, ~10ms on a 10KB query), we want to cache the parsed
|
67
|
+
# query here. Normally, if a client sends a slightly different form of a query, it's going
|
68
|
+
# to be in that alternate form every single time, so caching it can be a nice win.
|
69
|
+
atomically_update_cached_client_data_for(client.name) do |cached_client_data|
|
70
|
+
# We don't want the cached form of the query to persist the current variables, context, etc being used for this request.
|
71
|
+
cachable_query = prepare_query_for_execution(query, variables: {}, operation_name: nil, context: {})
|
72
|
+
|
73
|
+
# We use `_` here because Steep believes `client_data` could be nil. In general, this is
|
74
|
+
# true; it can be nil, but not at this callsite, because we are in a branch that is only
|
75
|
+
# executed when `client_data` is _not_ nil.
|
76
|
+
(_ = cached_client_data).with_updated_last_query(query_string, cachable_query)
|
77
|
+
end
|
78
|
+
|
79
|
+
[query, []]
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def client_data_for(client_name)
|
85
|
+
if (client_data = client_data_by_client_name[client_name])
|
86
|
+
client_data
|
87
|
+
else
|
88
|
+
atomically_update_cached_client_data_for(client_name) do |cached_data|
|
89
|
+
# We expect `cached_data` to be nil if we get here. However, it's technically possible for it
|
90
|
+
# not to be. If this `client_data_for` method was called with the same client from another thread
|
91
|
+
# in between the `client_data` fetch above and here, `cached_data` could not be populated.
|
92
|
+
# In that case, we don't want to pay the expense of re-building `ClientData` for no reason.
|
93
|
+
cached_data || ClientData.from(schema, provide_query_strings_for_client.call(client_name))
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Atomically updates the `ClientData` for the given `client_name`. All updates to our cache MUST go
|
99
|
+
# through this method to ensure there are no concurrency-related bugs. The caller should pass
|
100
|
+
# a block which will be yielded the current value in the cache (which can be `nil` initially); the
|
101
|
+
# block is then responsible for returning an updated copy of `ClientData` in the state that
|
102
|
+
# should be stored in the cache.
|
103
|
+
def atomically_update_cached_client_data_for(client_name)
|
104
|
+
client_cache_mutex.synchronize do
|
105
|
+
client_data_by_client_name[client_name] = yield client_data_by_client_name[client_name]
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def prepare_query_for_execution(query, variables:, operation_name:, context:)
|
110
|
+
::GraphQL::Query.new(
|
111
|
+
graphql_schema,
|
112
|
+
# Here we pass `document` instead of query string, so that we don't have to re-parse the query.
|
113
|
+
# However, when the document is nil, we still need to pass the query string.
|
114
|
+
query.document ? nil : query.query_string,
|
115
|
+
document: query.document,
|
116
|
+
variables: variables,
|
117
|
+
operation_name: operation_name,
|
118
|
+
context: context
|
119
|
+
)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
module ElasticGraph
|
10
|
+
module QueryRegistry
|
11
|
+
module QueryValidators
|
12
|
+
# Query validator implementation used for unregistered or anonymous clients.
|
13
|
+
ForUnregisteredClient = ::Data.define(:allow_unregistered_clients, :allow_any_query_for_clients) do
|
14
|
+
# @implements ForUnregisteredClient
|
15
|
+
def build_and_validate_query(query_string, client:, variables: {}, operation_name: nil, context: {})
|
16
|
+
query = yield
|
17
|
+
|
18
|
+
return [query, []] if allow_unregistered_clients
|
19
|
+
|
20
|
+
client_name = client&.name
|
21
|
+
return [query, []] if client_name && allow_any_query_for_clients.include?(client_name)
|
22
|
+
|
23
|
+
[query, [
|
24
|
+
"Client #{client&.description || "(unknown)"} is not a registered client, it is not in " \
|
25
|
+
"`allow_any_query_for_clients` and `allow_unregistered_clients` is false."
|
26
|
+
]]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|