elasticgraph-query_registry 0.18.0.0
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 +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
|