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