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 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